diff --git a/e2e/__screenshots__/Desktop-Chrome/token-context-menu.mock.spec.ts/token-action-sheet.png b/e2e/__screenshots__/Desktop-Chrome/token-context-menu.mock.spec.ts/token-action-sheet.png new file mode 100644 index 000000000..5a0c204b4 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/token-context-menu.mock.spec.ts/token-action-sheet.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/token-context-menu.mock.spec.ts/wallet-home-with-menu-buttons.png b/e2e/__screenshots__/Desktop-Chrome/token-context-menu.mock.spec.ts/wallet-home-with-menu-buttons.png new file mode 100644 index 000000000..28e9807ff Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/token-context-menu.mock.spec.ts/wallet-home-with-menu-buttons.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/token-menu-transfer-flow.mock.spec.ts/step-1-wallet-home.png b/e2e/__screenshots__/Desktop-Chrome/token-menu-transfer-flow.mock.spec.ts/step-1-wallet-home.png new file mode 100644 index 000000000..e2ceec381 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/token-menu-transfer-flow.mock.spec.ts/step-1-wallet-home.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/token-menu-transfer-flow.mock.spec.ts/step-2-no-more-buttons.png b/e2e/__screenshots__/Desktop-Chrome/token-menu-transfer-flow.mock.spec.ts/step-2-no-more-buttons.png new file mode 100644 index 000000000..e2ceec381 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/token-menu-transfer-flow.mock.spec.ts/step-2-no-more-buttons.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/token-context-menu.mock.spec.ts/token-action-sheet.png b/e2e/__screenshots__/Mobile-Chrome/token-context-menu.mock.spec.ts/token-action-sheet.png new file mode 100644 index 000000000..db0409598 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/token-context-menu.mock.spec.ts/token-action-sheet.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/token-context-menu.mock.spec.ts/wallet-home-with-menu-buttons.png b/e2e/__screenshots__/Mobile-Chrome/token-context-menu.mock.spec.ts/wallet-home-with-menu-buttons.png new file mode 100644 index 000000000..eb1f952fd Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/token-context-menu.mock.spec.ts/wallet-home-with-menu-buttons.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/token-menu-transfer-flow.mock.spec.ts/step-1-wallet-home.png b/e2e/__screenshots__/Mobile-Chrome/token-menu-transfer-flow.mock.spec.ts/step-1-wallet-home.png new file mode 100644 index 000000000..2b912c169 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/token-menu-transfer-flow.mock.spec.ts/step-1-wallet-home.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/token-menu-transfer-flow.mock.spec.ts/step-2-no-more-buttons.png b/e2e/__screenshots__/Mobile-Chrome/token-menu-transfer-flow.mock.spec.ts/step-2-no-more-buttons.png new file mode 100644 index 000000000..2b912c169 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/token-menu-transfer-flow.mock.spec.ts/step-2-no-more-buttons.png differ diff --git a/e2e/token-context-menu.mock.spec.ts b/e2e/token-context-menu.mock.spec.ts new file mode 100644 index 000000000..af274c9a6 --- /dev/null +++ b/e2e/token-context-menu.mock.spec.ts @@ -0,0 +1,277 @@ +import { test, expect, type Page } from '@playwright/test' +import { UI_TEXT } from './helpers/i18n' + +/** + * Token Context Menu E2E Tests + * + * Tests for the token context menu feature: + * - More button click triggers action sheet + * - Long press triggers action sheet on mobile + * - Transfer and destroy actions work correctly + */ + +// Test wallet data with BioForest chain tokens +const TEST_WALLET_DATA = { + wallets: [ + { + id: 'test-wallet-1', + name: '测试钱包', + address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', + chain: 'bfmeta', + chainAddresses: [ + { + chain: 'bfmeta', + address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', + tokens: [ + { symbol: 'BFM', name: 'BFMeta', balance: '10000', decimals: 8 }, + { symbol: 'USDT', name: 'Tether USD', balance: '500', decimals: 8 }, + { symbol: 'TEST', name: 'Test Token', balance: '1000', decimals: 8 }, + ], + }, + ], + encryptedMnemonic: { ciphertext: 'test', iv: 'test', salt: 'test' }, + createdAt: Date.now(), + tokens: [], + }, + ], + currentWalletId: 'test-wallet-1', + selectedChain: 'bfmeta', +} + +async function setupTestWallet(page: Page, language: string = 'zh-CN') { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: data.lang, currency: 'USD' })) + }, { wallet: TEST_WALLET_DATA, lang: language }) + + await page.goto('/') + await page.waitForLoadState('networkidle') +} + +async function waitForAppReady(page: Page) { + await page.locator('svg[aria-label="加载中"]').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}) +} + +test.describe('Token Context Menu - More Button', () => { + test('clicking more button opens action sheet', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + // Wait for wallet home to load + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + + // Find the more button on a token item (non-main asset like USDT) + const moreButton = page.locator('button[aria-label="更多操作"]').first() + + if (await moreButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await moreButton.click() + + // Wait for action sheet to appear + await page.waitForTimeout(300) + + // Check for transfer and destroy options + const transferOption = page.getByText('转账') + const destroyOption = page.getByText('销毁') + + const hasTransfer = await transferOption.isVisible().catch(() => false) + const hasDestroy = await destroyOption.isVisible().catch(() => false) + + console.log(`Action sheet visible - Transfer: ${hasTransfer}, Destroy: ${hasDestroy}`) + + // Take screenshot + await expect(page).toHaveScreenshot('token-action-sheet.png') + } else { + console.log('More button not visible - wallet may not have loaded correctly') + } + }) + + test('transfer action navigates to send page', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + + const moreButton = page.locator('button[aria-label="更多操作"]').first() + + if (await moreButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await moreButton.click() + await page.waitForTimeout(300) + + // Use more specific selector to avoid conflict with wallet home send button + const transferOption = page.locator('[role="dialog"], [data-state="open"]').getByText('转账') + if (await transferOption.isVisible().catch(() => false)) { + await transferOption.click() + await page.waitForTimeout(500) + + // Should navigate to send page + const url = page.url() + const hasSendInUrl = url.includes('send') || url.includes('transfer') + console.log(`Navigated to: ${url}, has send: ${hasSendInUrl}`) + + // Check for send page elements + const sendPageTitle = page.locator('h1') + if (await sendPageTitle.isVisible()) { + const titleText = await sendPageTitle.textContent() + console.log(`Send page title: ${titleText}`) + } + } + } + }) + + test('destroy action navigates to destroy page', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + + // Find more button for a non-main asset (USDT or TEST, not BFM) + const tokenItems = page.locator('[role="button"]').filter({ hasText: /USDT|TEST/ }) + const moreButton = tokenItems.first().locator('button[aria-label="更多操作"]') + + if (await moreButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await moreButton.click() + await page.waitForTimeout(300) + + const destroyOption = page.getByText('销毁') + if (await destroyOption.isVisible()) { + await destroyOption.click() + await page.waitForTimeout(500) + + // Should navigate to destroy page + const url = page.url() + const hasDestroyInUrl = url.includes('destroy') + console.log(`Navigated to: ${url}, has destroy: ${hasDestroyInUrl}`) + + // Take screenshot + await expect(page).toHaveScreenshot('destroy-page.png') + } else { + console.log('Destroy option not visible - may be main asset') + } + } else { + console.log('More button not visible for non-main asset') + } + }) +}) + +test.describe('Token Context Menu - Long Press', () => { + test('long press opens action sheet on mobile', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + + // Find a token item + const tokenItem = page.locator('[role="button"]').filter({ hasText: 'USDT' }).first() + + if (await tokenItem.isVisible({ timeout: 5000 }).catch(() => false)) { + const box = await tokenItem.boundingBox() + if (box) { + // Simulate long press using touch events + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) + + // Hold for 600ms (longer than 500ms threshold) + const startX = box.x + box.width / 2 + const startY = box.y + box.height / 2 + + await page.evaluate(async ({ x, y }) => { + const el = document.elementFromPoint(x, y) + if (el) { + el.dispatchEvent(new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 0, target: el, clientX: x, clientY: y })], + })) + + await new Promise(r => setTimeout(r, 600)) + + el.dispatchEvent(new TouchEvent('touchend', { + bubbles: true, + cancelable: true, + touches: [], + })) + } + }, { x: startX, y: startY }) + + await page.waitForTimeout(200) + + // Check for action sheet + const transferOption = page.getByText('转账') + const hasTransfer = await transferOption.isVisible().catch(() => false) + console.log(`Long press triggered action sheet: ${hasTransfer}`) + } + } + }) +}) + +test.describe('Token Context Menu - Right Click', () => { + test('right click opens action sheet on desktop', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + + // Find a token item + const tokenItem = page.locator('[role="button"]').filter({ hasText: 'USDT' }).first() + + if (await tokenItem.isVisible({ timeout: 5000 }).catch(() => false)) { + // Right click on token item + await tokenItem.click({ button: 'right' }) + await page.waitForTimeout(300) + + // Check for action sheet + const transferOption = page.getByText('转账') + const hasTransfer = await transferOption.isVisible().catch(() => false) + console.log(`Right click triggered action sheet: ${hasTransfer}`) + + if (hasTransfer) { + await expect(page).toHaveScreenshot('token-context-menu-right-click.png') + } + } + }) +}) + +test.describe('Token Context Menu - Main Asset Restrictions', () => { + test('main asset should not show destroy option', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + + // Find the main asset (BFM) + const mainAssetItem = page.locator('[role="button"]').filter({ hasText: 'BFM' }).first() + const moreButton = mainAssetItem.locator('button[aria-label="更多操作"]') + + if (await moreButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await moreButton.click() + await page.waitForTimeout(300) + + // Use more specific selector for action sheet options + const destroyOption = page.locator('[role="dialog"], [data-state="open"]').getByText('销毁') + const transferOption = page.locator('[role="dialog"], [data-state="open"]').getByText('转账') + const hasDestroy = await destroyOption.isVisible().catch(() => false) + + console.log(`Main asset (BFM) has destroy option: ${hasDestroy}`) + + // Main asset should not have destroy option + expect(hasDestroy).toBe(false) + + // But transfer should still be available + const hasTransfer = await transferOption.isVisible().catch(() => false) + expect(hasTransfer).toBe(true) + } else { + console.log('More button not visible for main asset - may have different behavior') + } + }) +}) + +test.describe('Token Context Menu - Screenshots', () => { + test('wallet home with context menu buttons', async ({ page }) => { + await setupTestWallet(page) + await waitForAppReady(page) + + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('wallet-home-with-menu-buttons.png') + }) +}) diff --git a/e2e/token-menu-transfer-flow.mock.spec.ts b/e2e/token-menu-transfer-flow.mock.spec.ts new file mode 100644 index 000000000..c72d96700 --- /dev/null +++ b/e2e/token-menu-transfer-flow.mock.spec.ts @@ -0,0 +1,248 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * Token Menu Transfer Flow - Complete E2E Test with Screenshots + * + * 完整的从菜单点击转账到发送页面的流程测试,每一步都截图 + */ + +const TEST_WALLET_DATA = { + wallets: [ + { + id: 'test-wallet-1', + name: '测试钱包', + address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', + chain: 'bfmeta', + chainAddresses: [ + { + chain: 'bfmeta', + address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', + tokens: [ + { symbol: 'BFM', name: 'BFMeta', balance: '10000', decimals: 8 }, + { symbol: 'USDT', name: 'Tether USD', balance: '500', decimals: 8 }, + { symbol: 'TEST', name: 'Test Token', balance: '1000', decimals: 8 }, + ], + }, + ], + encryptedMnemonic: { ciphertext: 'test', iv: 'test', salt: 'test' }, + createdAt: Date.now(), + tokens: [], + }, + ], + currentWalletId: 'test-wallet-1', + selectedChain: 'bfmeta', +} + +async function setupTestWallet(page: Page) { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: 'zh-CN', currency: 'USD' })) + }, { wallet: TEST_WALLET_DATA }) + + await page.goto('/') + await page.waitForLoadState('networkidle') +} + +async function waitForAppReady(page: Page) { + await page.locator('svg[aria-label="加载中"]').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}) +} + +test.describe('Token Menu Transfer Flow - Step by Step', () => { + test('complete transfer flow from menu click', async ({ page }) => { + // Step 1: Setup and load wallet home + await setupTestWallet(page) + await waitForAppReady(page) + + console.log('Step 1: Waiting for wallet home...') + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('step-1-wallet-home.png') + console.log('Step 1: Wallet home loaded') + + // Step 2: Find and click more button on BFM token + console.log('Step 2: Looking for more button...') + const moreButtons = page.locator('button[aria-label="更多操作"]') + const moreButtonCount = await moreButtons.count() + console.log(`Step 2: Found ${moreButtonCount} more buttons`) + + if (moreButtonCount === 0) { + console.log('Step 2: No more buttons found, taking screenshot') + await expect(page).toHaveScreenshot('step-2-no-more-buttons.png') + throw new Error('No more buttons found on token items') + } + + const firstMoreButton = moreButtons.first() + await expect(page).toHaveScreenshot('step-2-before-click-more.png') + await firstMoreButton.click() + console.log('Step 2: Clicked more button') + + // Step 3: Wait for action sheet and screenshot + console.log('Step 3: Waiting for action sheet...') + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('step-3-action-sheet-open.png') + + // Check what's in the dropdown menu (uses data-slot="dropdown-menu-content") + const menuContent = await page.locator('[data-slot="dropdown-menu-content"]').textContent().catch(() => '') + console.log(`Step 3: Menu content: ${menuContent}`) + + // Step 4: Find and click transfer option + console.log('Step 4: Looking for transfer option...') + // DropdownMenu uses data-slot="dropdown-menu-item" for menu items + const transferOption = page.locator('[data-slot="dropdown-menu-item"]').filter({ hasText: '转账' }) + const transferVisible = await transferOption.isVisible().catch(() => false) + console.log(`Step 4: Transfer option visible: ${transferVisible}`) + + if (!transferVisible) { + // Try alternative selectors + const allItems = page.locator('[data-slot="dropdown-menu-item"]') + const itemCount = await allItems.count() + console.log(`Step 4: Found ${itemCount} menu items`) + + for (let i = 0; i < itemCount; i++) { + const item = allItems.nth(i) + const text = await item.textContent().catch(() => '') + console.log(`Step 4: Item ${i}: "${text}"`) + } + + await expect(page).toHaveScreenshot('step-4-transfer-not-found.png') + throw new Error('Transfer option not found in dropdown menu') + } + + await transferOption.click() + console.log('Step 4: Clicked transfer option') + + // Step 5: Wait for navigation and screenshot send page + console.log('Step 5: Waiting for send page...') + await page.waitForTimeout(1000) + + const currentUrl = page.url() + console.log(`Step 5: Current URL: ${currentUrl}`) + await expect(page).toHaveScreenshot('step-5-after-transfer-click.png') + + // Step 6: Check send page content + console.log('Step 6: Checking send page content...') + const pageContent = await page.content() + const hasError = pageContent.includes('error') || pageContent.includes('Error') || pageContent.includes('错误') + console.log(`Step 6: Page has error: ${hasError}`) + + // Check for specific elements + const h1 = page.locator('h1') + if (await h1.isVisible().catch(() => false)) { + const title = await h1.textContent() + console.log(`Step 6: Page title: ${title}`) + } + + // Check for address input + const addressInput = page.locator('[data-testid="address-input"], input[placeholder*="地址"]') + const hasAddressInput = await addressInput.isVisible().catch(() => false) + console.log(`Step 6: Has address input: ${hasAddressInput}`) + + // Check for amount input + const amountInput = page.locator('[data-testid="amount-input"], input[placeholder="0"]') + const hasAmountInput = await amountInput.isVisible().catch(() => false) + console.log(`Step 6: Has amount input: ${hasAmountInput}`) + + await expect(page).toHaveScreenshot('step-6-send-page-content.png') + + // Step 7: Check for any error messages + console.log('Step 7: Checking for error messages...') + const errorElements = page.locator('.text-destructive, .text-red-500, [role="alert"]') + const errorCount = await errorElements.count() + console.log(`Step 7: Found ${errorCount} error elements`) + + for (let i = 0; i < errorCount; i++) { + const errorText = await errorElements.nth(i).textContent() + console.log(`Step 7: Error ${i}: "${errorText}"`) + } + + if (errorCount > 0) { + await expect(page).toHaveScreenshot('step-7-errors-found.png') + } + + // Final assertion + expect(currentUrl).toContain('send') + }) + + test('complete destroy flow from menu click', async ({ page }) => { + // Step 1: Setup and load wallet home + await setupTestWallet(page) + await waitForAppReady(page) + + console.log('Step 1: Waiting for wallet home...') + await page.waitForSelector('[data-testid="wallet-home"]', { timeout: 10000 }) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('destroy-step-1-wallet-home.png') + + // Step 2: Find USDT token (non-main asset) and click its more button + console.log('Step 2: Looking for USDT token more button...') + const usdtToken = page.locator('[role="button"]').filter({ hasText: 'USDT' }) + const usdtVisible = await usdtToken.isVisible().catch(() => false) + console.log(`Step 2: USDT token visible: ${usdtVisible}`) + + if (!usdtVisible) { + await expect(page).toHaveScreenshot('destroy-step-2-usdt-not-found.png') + throw new Error('USDT token not found') + } + + const moreButton = usdtToken.locator('button[aria-label="更多操作"]') + const moreVisible = await moreButton.isVisible().catch(() => false) + console.log(`Step 2: More button visible: ${moreVisible}`) + + if (!moreVisible) { + await expect(page).toHaveScreenshot('destroy-step-2-more-not-found.png') + throw new Error('More button not found on USDT token') + } + + await moreButton.click() + console.log('Step 2: Clicked more button') + + // Step 3: Wait for action sheet + console.log('Step 3: Waiting for action sheet...') + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('destroy-step-3-action-sheet.png') + + // Step 4: Click destroy option + console.log('Step 4: Looking for destroy option...') + const destroyOption = page.locator('[role="dialog"], [data-state="open"]').getByText('销毁') + const destroyVisible = await destroyOption.isVisible().catch(() => false) + console.log(`Step 4: Destroy option visible: ${destroyVisible}`) + + if (!destroyVisible) { + await expect(page).toHaveScreenshot('destroy-step-4-destroy-not-found.png') + throw new Error('Destroy option not found') + } + + await destroyOption.click() + console.log('Step 4: Clicked destroy option') + + // Step 5: Wait for destroy page + console.log('Step 5: Waiting for destroy page...') + await page.waitForTimeout(1000) + + const currentUrl = page.url() + console.log(`Step 5: Current URL: ${currentUrl}`) + await expect(page).toHaveScreenshot('destroy-step-5-destroy-page.png') + + // Step 6: Check destroy page content + console.log('Step 6: Checking destroy page content...') + const h1 = page.locator('h1') + if (await h1.isVisible().catch(() => false)) { + const title = await h1.textContent() + console.log(`Step 6: Page title: ${title}`) + } + + // Check for error messages + const errorElements = page.locator('.text-destructive, .text-red-500, [role="alert"]') + const errorCount = await errorElements.count() + console.log(`Step 6: Found ${errorCount} error elements`) + + for (let i = 0; i < errorCount; i++) { + const errorText = await errorElements.nth(i).textContent() + console.log(`Step 6: Error ${i}: "${errorText}"`) + } + + await expect(page).toHaveScreenshot('destroy-step-6-page-content.png') + + expect(currentUrl).toContain('destroy') + }) +}) diff --git a/src/components/asset/asset-selector.test.tsx b/src/components/asset/asset-selector.test.tsx index ff41ce9c7..878dc34e5 100644 --- a/src/components/asset/asset-selector.test.tsx +++ b/src/components/asset/asset-selector.test.tsx @@ -61,7 +61,7 @@ describe('AssetSelector', () => { expect(screen.getByText('BFM')).toBeInTheDocument() }) - it('opens sheet on click', () => { + it('opens dropdown on click', () => { render( { fireEvent.click(screen.getByTestId('asset-selector')) - // Sheet should open and show assets + // Dropdown should open and show assets expect(screen.getByText('BFMeta')).toBeInTheDocument() expect(screen.getByText('Tether USD')).toBeInTheDocument() }) @@ -89,7 +89,7 @@ describe('AssetSelector', () => { />, ) - // Open sheet + // Open dropdown fireEvent.click(screen.getByTestId('asset-selector')) // Click on USDT @@ -109,7 +109,7 @@ describe('AssetSelector', () => { />, ) - // Open sheet + // Open dropdown fireEvent.click(screen.getByTestId('asset-selector')) // Only ETH should be visible @@ -129,7 +129,7 @@ describe('AssetSelector', () => { />, ) - // Open sheet + // Open dropdown fireEvent.click(screen.getByTestId('asset-selector')) expect(screen.getByText('暂无可选资产')).toBeInTheDocument() @@ -161,7 +161,7 @@ describe('AssetSelector', () => { />, ) - expect(screen.getByText('余额:')).toBeInTheDocument() + expect(screen.getByText(/余额/)).toBeInTheDocument() }) it('hides balance when showBalance is false', () => { @@ -175,7 +175,7 @@ describe('AssetSelector', () => { />, ) - expect(screen.queryByText('余额:')).not.toBeInTheDocument() + expect(screen.queryByText(/余额/)).not.toBeInTheDocument() }) it('uses custom placeholder', () => { @@ -192,7 +192,7 @@ describe('AssetSelector', () => { expect(screen.getByText('Pick an asset')).toBeInTheDocument() }) - it('highlights selected asset in list', () => { + it('shows selected asset in list', () => { render( { />, ) - // Open sheet + // Open dropdown fireEvent.click(screen.getByTestId('asset-selector')) - // The selected asset (USDT) should have a different background style - // We can verify the sheet is open and shows assets + // Assets should be visible expect(screen.getByText('Tether USD')).toBeInTheDocument() expect(screen.getByText('BFMeta')).toBeInTheDocument() }) diff --git a/src/components/asset/asset-selector.tsx b/src/components/asset/asset-selector.tsx index a4d974bf0..74467a53c 100644 --- a/src/components/asset/asset-selector.tsx +++ b/src/components/asset/asset-selector.tsx @@ -1,49 +1,42 @@ /** * AssetSelector - 资产选择器组件 - * + * * 用于在转账、销毁等场景中选择资产 */ -import { useState, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { cn } from '@/lib/utils' -import { TokenIcon } from '@/components/wallet/token-icon' -import { AmountDisplay } from '@/components/common' -import { - Sheet, - SheetTrigger, - SheetContent, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet' -import { IconChevronDown, IconCheck } from '@tabler/icons-react' -import type { TokenInfo } from '@/components/token/token-item' +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; +import { TokenIcon } from '@/components/wallet/token-icon'; +import { AmountDisplay } from '@/components/common'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import type { TokenInfo } from '@/components/token/token-item'; export interface AssetSelectorProps { /** 当前选中的资产 */ - selectedAsset: TokenInfo | null + selectedAsset: TokenInfo | null; /** 可选资产列表 */ - assets: TokenInfo[] + assets: TokenInfo[]; /** 选择资产回调 */ - onSelect: (asset: TokenInfo) => void + onSelect: (asset: TokenInfo) => void; /** 是否禁用 */ - disabled?: boolean + disabled?: boolean; /** 排除的资产符号列表 (如主资产不可销毁) */ - excludeAssets?: string[] + excludeAssets?: string[]; /** 是否显示余额 */ - showBalance?: boolean + showBalance?: boolean; /** 占位文本 */ - placeholder?: string + placeholder?: string; /** 额外的 class */ - className?: string + className?: string; /** 测试 ID */ - testId?: string + testId?: string; } /** * 资产选择器 - * - * 点击展开 Sheet 显示可选资产列表 + * + * 使用 Select 组件展示可选资产列表 */ export function AssetSelector({ selectedAsset, @@ -56,108 +49,89 @@ export function AssetSelector({ className, testId, }: AssetSelectorProps) { - const { t } = useTranslation('transaction') - const [open, setOpen] = useState(false) + const { t } = useTranslation('transaction'); // 过滤可选资产 const availableAssets = useMemo(() => { - if (excludeAssets.length === 0) return assets - const excludeSet = new Set(excludeAssets.map((s) => s.toUpperCase())) - return assets.filter((asset) => !excludeSet.has(asset.symbol.toUpperCase())) - }, [assets, excludeAssets]) + if (excludeAssets.length === 0) return assets; + const excludeSet = new Set(excludeAssets.map((s) => s.toUpperCase())); + return assets.filter((asset) => !excludeSet.has(asset.symbol.toUpperCase())); + }, [assets, excludeAssets]); - const handleSelect = (asset: TokenInfo) => { - onSelect(asset) - setOpen(false) - } + // 生成唯一 key + const getAssetKey = (asset: TokenInfo) => `${asset.chain}-${asset.symbol}`; - const displayPlaceholder = placeholder ?? t('assetSelector.selectAsset', '选择资产') + const handleValueChange = (value: string) => { + const asset = availableAssets.find((a) => getAssetKey(a) === value); + if (asset) { + onSelect(asset); + } + }; - return ( - - - {selectedAsset ? ( -
- -
- {selectedAsset.symbol} - {showBalance && ( - - {t('assetSelector.balance', '余额')}: - - )} -
-
- ) : ( - {displayPlaceholder} - )} - -
+ const displayPlaceholder = placeholder ?? t('assetSelector.selectAsset', '选择资产'); + const selectedValue = selectedAsset ? getAssetKey(selectedAsset) : undefined; - - - {t('assetSelector.selectAsset', '选择资产')} - + // 渲染 trigger 内容 + const renderTriggerContent = () => { + if (!selectedAsset) { + return {displayPlaceholder}; + } -
- {availableAssets.length === 0 ? ( -
-

{t('assetSelector.noAssets', '暂无可选资产')}

-
- ) : ( -
- {availableAssets.map((asset) => { - const isSelected = selectedAsset?.symbol === asset.symbol && selectedAsset?.chain === asset.chain - return ( - - ) - })} -
+ return ( +
+ +
+ {selectedAsset.symbol} + {showBalance && ( + + {t('assetSelector.balance', '余额')}:{' '} + + )}
+
+ ); + }; - {/* Safe area */} -
- - - ) + return ( + + ); } diff --git a/src/components/common/amount-display.tsx b/src/components/common/amount-display.tsx index 5d7550518..21c6abd6a 100644 --- a/src/components/common/amount-display.tsx +++ b/src/components/common/amount-display.tsx @@ -1,8 +1,11 @@ import NumberFlow from '@number-flow/react' +import { useState, useRef, useLayoutEffect, useCallback } from 'react' import { cn } from '@/lib/utils'; type AmountSign = 'auto' | 'always' | 'never'; type AmountColor = 'auto' | 'default' | 'positive' | 'negative'; +/** 小数位显示模式:false=隐藏尾随零, true=显示完整小数位, 'auto'=自适应容器宽度 */ +type FixedDecimalsMode = boolean | 'auto'; interface AmountDisplayProps { value: string | number; @@ -26,11 +29,50 @@ interface AmountDisplayProps { mono?: boolean | undefined; /** 是否启用动画 (默认 true) */ animated?: boolean | undefined; - /** 始终显示完整小数位 (如 0.00000000) */ - fixedDecimals?: boolean | undefined; + /** 小数位显示模式:false=隐藏尾随零, true=显示完整, 'auto'=自适应宽度 */ + fixedDecimals?: FixedDecimalsMode | undefined; className?: string | undefined; } +// Canvas 测量文字宽度(不触发回流) +let measureCanvas: HTMLCanvasElement | null = null; +let measureCtx: CanvasRenderingContext2D | null = null; + +function measureText(text: string, font: string): number { + if (typeof document === 'undefined') { + return text.length * 8; + } + + if (!measureCanvas) { + measureCanvas = document.createElement('canvas'); + measureCtx = measureCanvas.getContext('2d'); + } + + if (!measureCtx) { + return text.length * 8; + } + + measureCtx.font = font; + return measureCtx.measureText(text).width; +} + +// 格式化数字为字符串(用于测量宽度) +function formatNumberToString(num: number, minDecimals: number, maxDecimals: number): string { + return num.toLocaleString('en-US', { + minimumFractionDigits: minDecimals, + maximumFractionDigits: maxDecimals, + }); +} + +// 计算数值的有效小数位数(去除尾随零后的位数) +function getEffectiveDecimals(num: number, maxDecimals: number): number { + if (num === 0) return 0; + const str = Math.abs(num).toFixed(maxDecimals); + const decimalPart = str.split('.')[1] || ''; + const trimmed = decimalPart.replace(/0+$/, ''); + return trimmed.length; +} + // 格式化数字 (仅用于非动画模式或 compact 模式) function formatAmount( value: string | number, @@ -83,6 +125,59 @@ const weightClasses = { bold: 'font-bold', }; +// 自适应小数位数 Hook +function useAutoDecimals( + containerRef: React.RefObject, + numValue: number, + maxDecimals: number, + signChar: string, + symbol: string | undefined, +): number { + const minDecimals = getEffectiveDecimals(numValue, maxDecimals); + const [displayDecimals, setDisplayDecimals] = useState(minDecimals); + + const updateDecimals = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + const style = getComputedStyle(container); + const font = `${style.fontSize} ${style.fontFamily}`; + const symbolSpace = symbol ? measureText(` ${symbol}`, font) : 0; + const signSpace = signChar ? measureText(signChar, font) : 0; + const availableWidth = container.clientWidth - symbolSpace - signSpace; + + if (availableWidth <= 0) { + setDisplayDecimals(minDecimals); + return; + } + + // 从最大小数位开始,找到能容纳的最大位数 + for (let d = maxDecimals; d >= minDecimals; d--) { + const text = formatNumberToString(Math.abs(numValue), d, maxDecimals); + const textWidth = measureText(text, font); + if (textWidth <= availableWidth) { + setDisplayDecimals(d); + return; + } + } + setDisplayDecimals(minDecimals); + }, [numValue, maxDecimals, minDecimals, signChar, symbol]); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + updateDecimals(); + + const observer = new ResizeObserver(updateDecimals); + observer.observe(container); + + return () => observer.disconnect(); + }, [updateDecimals]); + + return displayDecimals; +} + export function AmountDisplay({ value, symbol, @@ -96,11 +191,45 @@ export function AmountDisplay({ weight = 'normal', mono = true, animated = true, - fixedDecimals = false, + fixedDecimals = 'auto', className, }: AmountDisplayProps) { + const containerRef = useRef(null); const baseClassName = cn(sizeClasses[size], weightClasses[weight], mono && 'font-mono', className); + // 预计算数值信息 + const { isNegative, isZero, numValue } = formatAmount(value, decimals, compact); + + // 计算符号 + let signChar = ''; + if (sign === 'always' && !isZero) { + signChar = isNegative ? '-' : '+'; + } else if (sign === 'auto' && isNegative) { + signChar = '-'; + } + + // auto 模式:自适应小数位数 + const autoDecimals = useAutoDecimals( + containerRef, + numValue, + decimals, + signChar, + symbol, + ); + + // 根据 fixedDecimals 模式确定 minimumFractionDigits + const effectiveMinDecimals = + fixedDecimals === 'auto' + ? autoDecimals + : fixedDecimals === true + ? decimals + : 0; + + const format = { + minimumFractionDigits: effectiveMinDecimals, + maximumFractionDigits: decimals, + }; + if (hidden) { return ( @@ -110,11 +239,6 @@ export function AmountDisplay({ ); } - const format = { - minimumFractionDigits: fixedDecimals ? decimals : 0, - maximumFractionDigits: decimals, - }; - // 加载状态:显示 0 配合呼吸动画 if (loading) { return ( @@ -134,15 +258,7 @@ export function AmountDisplay({ ); } - const { formatted, isNegative, isZero, numValue } = formatAmount(value, decimals, compact); - - // 计算符号 - let signChar = ''; - if (sign === 'always' && !isZero) { - signChar = isNegative ? '-' : '+'; - } else if (sign === 'auto' && isNegative) { - signChar = '-'; - } + const { formatted } = formatAmount(value, decimals, compact); // 计算颜色 let colorClass = ''; @@ -156,7 +272,41 @@ export function AmountDisplay({ const coloredClassName = cn(baseClassName, colorClass); - // 使用 NumberFlow 进行动画显示 (compact 模式不使用动画) + // auto 模式需要使用相对定位容器 + if (fixedDecimals === 'auto') { + const minText = formatNumberToString(Math.abs(numValue), getEffectiveDecimals(numValue, decimals), decimals); + const a11yLabel = `${signChar}${formatted}${symbol ? ` ${symbol}` : ''}`; + + return ( + + {/* 隐藏占位符:最小宽度(有效数字) */} + + {/* 绝对定位的实际内容 */} + + + ); + } + + // 非 auto 模式:使用 NumberFlow 进行动画显示 (compact 模式不使用动画) if (animated && !compact) { const a11yLabel = `${signChar}${formatted}${symbol ? ` ${symbol}` : ''}`; return ( diff --git a/src/components/common/animated-number.tsx b/src/components/common/animated-number.tsx index 56a424495..e3031bbb6 100644 --- a/src/components/common/animated-number.tsx +++ b/src/components/common/animated-number.tsx @@ -11,6 +11,10 @@ import NumberFlow from '@number-flow/react' import { cn } from '@/lib/utils' +import { AmountDisplay } from './amount-display' + +/** 小数位显示模式:false=隐藏尾随零, true=显示完整小数位, 'auto'=自适应容器宽度 */ +type FixedDecimalsMode = boolean | 'auto'; interface AnimatedNumberProps { /** The number value to display */ @@ -19,8 +23,8 @@ interface AnimatedNumberProps { loading?: boolean | undefined /** Decimal places to show (default: 8) */ decimals?: number | undefined - /** Always show full decimal places (e.g., 0.00000000) */ - fixedDecimals?: boolean | undefined + /** 小数位显示模式:false=隐藏尾随零, true=显示完整, 'auto'=自适应宽度 */ + fixedDecimals?: FixedDecimalsMode | undefined /** Formatting locale (default: en-US) */ locale?: string | undefined /** Additional CSS classes */ @@ -38,10 +42,24 @@ export function AnimatedNumber({ value, loading = false, decimals = 8, - fixedDecimals = false, + fixedDecimals = 'auto', locale = 'en-US', className, }: AnimatedNumberProps) { + // auto 模式:使用 AmountDisplay 的自适应逻辑 + if (fixedDecimals === 'auto') { + return ( + + ) + } + const format = { minimumFractionDigits: fixedDecimals ? decimals : 0, maximumFractionDigits: decimals, @@ -95,8 +113,8 @@ interface AnimatedAmountProps { loading?: boolean | undefined /** Decimal places to show (default: 8) */ decimals?: number | undefined - /** Always show full decimal places (e.g., 0.00000000) */ - fixedDecimals?: boolean | undefined + /** 小数位显示模式:false=隐藏尾随零, true=显示完整, 'auto'=自适应宽度 */ + fixedDecimals?: FixedDecimalsMode | undefined /** Formatting locale (default: en-US) */ locale?: string | undefined /** Additional CSS classes */ @@ -107,7 +125,7 @@ export function AnimatedAmount({ value, loading = false, decimals = 8, - fixedDecimals = false, + fixedDecimals = 'auto', locale = 'en-US', className, }: AnimatedAmountProps) { diff --git a/src/components/token/balance-display.test.tsx b/src/components/token/balance-display.test.tsx index ca6fe6f0b..e4262853f 100644 --- a/src/components/token/balance-display.test.tsx +++ b/src/components/token/balance-display.test.tsx @@ -62,10 +62,11 @@ describe('BalanceDisplay', () => { it('applies size classes correctly', () => { const { rerender } = render() - expect(screen.getByText('100')).toHaveClass('text-sm') + // Auto mode uses aria-label for accessibility + expect(screen.getByRole('text', { name: '100' })).toHaveClass('text-sm') rerender() - expect(screen.getByText('100')).toHaveClass('text-base') + expect(screen.getByRole('text', { name: '100' })).toHaveClass('text-base') }) it('renders without fiat when not provided', () => { diff --git a/src/components/token/index.ts b/src/components/token/index.ts index 59ec5d92b..56867c886 100644 --- a/src/components/token/index.ts +++ b/src/components/token/index.ts @@ -1,3 +1,3 @@ export { BalanceDisplay, formatNumber } from './balance-display' -export { TokenItem, type TokenInfo, type TokenItemContext } from './token-item' +export { TokenItem, type TokenInfo, type TokenItemContext, type TokenMenuItem } from './token-item' export { TokenList } from './token-list' diff --git a/src/components/token/token-item-actions.test.tsx b/src/components/token/token-item-actions.test.tsx index 66d5125f3..d9dd56460 100644 --- a/src/components/token/token-item-actions.test.tsx +++ b/src/components/token/token-item-actions.test.tsx @@ -82,73 +82,25 @@ describe('TokenItem actions', () => { }) }) - describe('context menu (onContextMenu)', () => { - it('should trigger onContextMenu when provided', () => { - const onContextMenu = vi.fn() + describe('menuItems dropdown', () => { + it('should show more button when menuItems is provided', () => { + const menuItems = () => [ + { label: 'Transfer', onClick: vi.fn() }, + { label: 'Destroy', onClick: vi.fn(), variant: 'destructive' as const }, + ] render( ) - const tokenItem = screen.getByTestId('token-item') - fireEvent.contextMenu(tokenItem) - - expect(onContextMenu).toHaveBeenCalledWith( - expect.any(Object), // event - mockToken, - expect.objectContaining({ - chainType: 'bfmeta', - isBioforestChain: true, - }) - ) - }) - - it('more button click should trigger onContextMenu', () => { - const onContextMenu = vi.fn() - - render( - - ) - - const moreButton = screen.getByLabelText(/more|更多/i) - fireEvent.click(moreButton) - - expect(onContextMenu).toHaveBeenCalled() - }) - - it('long press should trigger onContextMenu on touch devices', async () => { - const onContextMenu = vi.fn() - - render( - - ) - - const tokenItem = screen.getByTestId('token-item') - - // Simulate long press via touch events - fireEvent.touchStart(tokenItem) - - // Wait for long press timeout (typically 500ms) - await new Promise(resolve => setTimeout(resolve, 600)) - - fireEvent.touchEnd(tokenItem) - - expect(onContextMenu).toHaveBeenCalled() + expect(screen.getByLabelText(/more|更多/i)).toBeInTheDocument() }) - it('should not show more button when onContextMenu is not provided', () => { + it('should not show more button when menuItems is not provided', () => { render( { /> ) - expect(screen.queryByLabelText(/more|操作/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/more|更多/i)).not.toBeInTheDocument() }) }) diff --git a/src/components/token/token-item.test.tsx b/src/components/token/token-item.test.tsx index 2d74cb0dd..04810bc4b 100644 --- a/src/components/token/token-item.test.tsx +++ b/src/components/token/token-item.test.tsx @@ -56,21 +56,23 @@ describe('TokenItem', () => { it('shows 24h change when showChange is true', () => { render() - // AmountDisplay shows +2.5 (without trailing zeros) - expect(screen.getByText('+2.5')).toBeInTheDocument() + // Auto mode uses aria-label for accessibility + expect(screen.getByRole('text', { name: '+2.5' })).toBeInTheDocument() // % is in the same

as the fiat value expect(screen.getByText(/≈.*%/)).toBeInTheDocument() }) it('shows negative change with correct color', () => { render() - const changeText = screen.getByText('-3.5') + // Auto mode uses aria-label, check class on the element with role="text" + const changeText = screen.getByRole('text', { name: '-3.5' }) expect(changeText).toHaveClass('text-destructive') }) it('shows positive change with correct color', () => { render() - const changeText = screen.getByText('+2.5') + // Auto mode uses aria-label, check class on the element with role="text" + const changeText = screen.getByRole('text', { name: '+2.5' }) expect(changeText).toHaveClass('text-green-500') }) diff --git a/src/components/token/token-item.tsx b/src/components/token/token-item.tsx index e85e299cf..940c031ae 100644 --- a/src/components/token/token-item.tsx +++ b/src/components/token/token-item.tsx @@ -4,8 +4,16 @@ import { useTranslation } from 'react-i18next'; import { IconDotsVertical } from '@tabler/icons-react'; import { ChainIcon, TokenIcon, type ChainType } from '../wallet'; import { AmountDisplay, AnimatedAmount } from '../common'; +import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { currencies, useCurrency } from '@/stores'; import { getExchangeRate, useExchangeRate } from '@/hooks/use-exchange-rate'; +import { Button } from '../ui/button'; function parseFiatNumber(input: string): number | null { const normalized = input.replaceAll(',', '').trim(); @@ -39,6 +47,16 @@ export interface TokenItemContext { canDestroy: boolean; } +/** Menu item for token actions dropdown */ +export interface TokenMenuItem { + label: string; + icon?: React.ReactNode; + onClick: () => void; + variant?: 'default' | 'destructive'; + /** Condition to show this item (default: true) */ + show?: boolean; +} + interface TokenItemProps { token: TokenInfo; onClick?: (() => void) | undefined; @@ -47,20 +65,18 @@ interface TokenItemProps { loading?: boolean | undefined; className?: string | undefined; testId?: string | undefined; - /** - * Render prop for custom actions (e.g., dropdown menu) + /** + * Render prop for custom actions (e.g., action buttons on the right) * Receives the token and context for conditional rendering */ renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined; /** Main asset symbol of the chain (used to determine isMainAsset) */ mainAssetSymbol?: string | undefined; /** - * Context menu handler - triggered by: - * - Right click (desktop) - * - Long press (mobile) - * - More button click + * Menu items for the context menu (right-click on desktop) + * Function receives token and context, returns array of menu items */ - onContextMenu?: ((event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => void) | undefined; + menuItems?: ((token: TokenInfo, context: TokenItemContext) => TokenMenuItem[]) | undefined; } // BioForest chain types that support asset destruction @@ -75,30 +91,24 @@ const BIOFOREST_CHAINS = new Set([ 'malibu', ]); -export function TokenItem({ - token, - onClick, - showChange = false, - loading = false, - className, +export function TokenItem({ + token, + onClick, + showChange = false, + loading = false, + className, testId, renderActions, mainAssetSymbol, - onContextMenu, + menuItems, }: TokenItemProps) { const isClickable = !!onClick; const { t } = useTranslation(['currency', 'common']); const currency = useCurrency(); - - // Long press support for mobile - const longPressTimerRef = React.useRef | null>(null); - const longPressTriggeredRef = React.useRef(false); // Compute context for renderActions const isBioforestChain = BIOFOREST_CHAINS.has(token.chain); - const isMainAsset = mainAssetSymbol - ? token.symbol.toUpperCase() === mainAssetSymbol.toUpperCase() - : false; + const isMainAsset = mainAssetSymbol ? token.symbol.toUpperCase() === mainAssetSymbol.toUpperCase() : false; const canDestroy = isBioforestChain && !isMainAsset; const context: TokenItemContext = { @@ -108,56 +118,6 @@ export function TokenItem({ canDestroy, }; - // Context menu handlers - const handleContextMenu = React.useCallback((e: React.MouseEvent) => { - if (onContextMenu) { - e.preventDefault(); - e.stopPropagation(); - onContextMenu(e, token, context); - } - }, [onContextMenu, token, context]); - - const handleMoreButtonClick = React.useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e, token, context); - }, [onContextMenu, token, context]); - - // Long press handlers for touch devices - const handleTouchStart = React.useCallback(() => { - if (!onContextMenu) return; - - longPressTriggeredRef.current = false; - longPressTimerRef.current = setTimeout(() => { - longPressTriggeredRef.current = true; - onContextMenu(null, token, context); - }, 500); // 500ms long press - }, [onContextMenu, token, context]); - - const handleTouchEnd = React.useCallback(() => { - if (longPressTimerRef.current) { - clearTimeout(longPressTimerRef.current); - longPressTimerRef.current = null; - } - }, []); - - const handleTouchMove = React.useCallback(() => { - // Cancel long press if user moves finger - if (longPressTimerRef.current) { - clearTimeout(longPressTimerRef.current); - longPressTimerRef.current = null; - } - }, []); - - // Cleanup timer on unmount - React.useEffect(() => { - return () => { - if (longPressTimerRef.current) { - clearTimeout(longPressTimerRef.current); - } - }; - }, []); - const shouldFetchRate = token.fiatValue !== undefined && currency !== 'USD'; const { data: exchangeRateData, @@ -184,95 +144,100 @@ export function TokenItem({ : t('exchange.unavailable') : null; - return ( -

e.key === 'Enter' && onClick?.() : undefined} - onContextMenu={handleContextMenu} - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - onTouchMove={handleTouchMove} - aria-label={isClickable ? t('common:a11y.tokenDetails', { token: token.symbol }) : undefined} - className={cn( - '@container flex items-center gap-3 rounded-xl p-3 transition-colors', - isClickable && 'hover:bg-muted/50 active:bg-muted cursor-pointer', - className, - )} - > + // Content to render inside the item + const itemContent = ( + <> {/* Token Icon */} -
- - {/* Chain badge */} - -
+ +
+ + {/* Chain badge - 40% of parent size, positioned at bottom-right */} + +
+
{/* Token Info */} -
-

{token.symbol}

-

{token.name}

-
+ + {token.symbol} + {token.name} + + + {/* Balance and Actions */} + +
+ + {displayFiatValue && !loading && ( +

+ ≈ {fiatSymbol} + + {showChange && token.change24h !== undefined && ( + + )} + {showChange && token.change24h !== undefined && '%'} +

+ )} + {loading &&

≈ --

} +
+
+ + ); - {/* Balance */} -
- - {displayFiatValue && !loading && ( -

- ≈ {fiatSymbol} - - {showChange && token.change24h !== undefined && ( - - )} - {showChange && token.change24h !== undefined && '%'} -

- )} - {loading && ( -

- ≈ -- -

- )} -
+ // Get menu items if provided + const items = menuItems?.(token, context).filter((item) => item.show !== false) ?? []; - {/* More button - visible when onContextMenu is provided */} - {onContextMenu && ( - + return ( + : undefined} + onClick={onClick} + aria-label={isClickable ? t('common:a11y.tokenDetails', { token: token.symbol }) : undefined} + className={cn(isClickable && 'hover:bg-muted/50 active:bg-muted cursor-pointer', className)} + > + {itemContent} + + {/* More button with dropdown menu - min 44x44 touch target */} + {items.length > 0 && ( + + + + + + {items.map((item, index) => ( + + {item.icon} + {item.label} + + ))} + + )} - {/* Custom actions slot (deprecated: use onContextMenu instead) */} - {renderActions && !onContextMenu && ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {renderActions(token, context)} -
+ {/* Custom actions slot */} + {renderActions && !menuItems && ( + e.stopPropagation()}>{renderActions(token, context)} )} -
+ ); } diff --git a/src/components/token/token-list.test.tsx b/src/components/token/token-list.test.tsx index db9392b69..e64b0930d 100644 --- a/src/components/token/token-list.test.tsx +++ b/src/components/token/token-list.test.tsx @@ -52,7 +52,8 @@ describe('TokenList', () => { const handleClick = vi.fn() render() - await userEvent.click(screen.getByText('USDT').closest('[role="button"]')!) + // Item component uses data-slot="item" instead of role="button" + await userEvent.click(screen.getByText('USDT').closest('[data-slot="item"]')!) expect(handleClick).toHaveBeenCalledWith(mockTokens[0]) }) @@ -61,7 +62,8 @@ describe('TokenList', () => { { ...mockTokens[0]!, fiatValue: '100', change24h: 5.5 }, ] render() - expect(screen.getByText('+5.5')).toBeInTheDocument() + // Auto mode renders text in multiple places, use aria-label to find the element + expect(screen.getByRole('text', { name: '+5.5' })).toBeInTheDocument() expect(screen.getByText(/≈.*%/)).toBeInTheDocument() }) diff --git a/src/components/token/token-list.tsx b/src/components/token/token-list.tsx index 2e6134c6f..67399f285 100644 --- a/src/components/token/token-list.tsx +++ b/src/components/token/token-list.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils' -import { TokenItem, type TokenInfo, type TokenItemContext } from './token-item' +import { TokenItem, type TokenInfo, type TokenItemContext, type TokenMenuItem } from './token-item' import { EmptyState } from '../common/empty-state' import { SkeletonList } from '../common/skeleton' @@ -16,12 +16,14 @@ interface TokenListProps { emptyAction?: React.ReactNode | undefined className?: string | undefined testId?: string | undefined - /** Render prop for custom actions per token item (deprecated: use onContextMenu) */ + /** Render prop for custom actions per token item (deprecated: use menuItems) */ renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined /** Main asset symbol for the chain (used by renderActions context) */ mainAssetSymbol?: string | undefined - /** Context menu handler for token items */ + /** Context menu handler for token items (deprecated: use menuItems) */ onContextMenu?: ((event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => void) | undefined + /** Menu items for dropdown menu (recommended approach) */ + menuItems?: ((token: TokenInfo, context: TokenItemContext) => TokenMenuItem[]) | undefined } export function TokenList({ @@ -38,6 +40,7 @@ export function TokenList({ renderActions, mainAssetSymbol, onContextMenu, + menuItems, }: TokenListProps) { if (loading) { return @@ -72,6 +75,7 @@ export function TokenList({ renderActions={renderActions} mainAssetSymbol={mainAssetSymbol} onContextMenu={onContextMenu} + menuItems={menuItems} {...(onTokenClick && { onClick: () => onTokenClick(token) })} /> ))} diff --git a/src/components/transaction/fee-display.tsx b/src/components/transaction/fee-display.tsx index bb0414de2..c3bcf2bd2 100644 --- a/src/components/transaction/fee-display.tsx +++ b/src/components/transaction/fee-display.tsx @@ -76,6 +76,7 @@ export function FeeDisplay({ size="sm" decimals={8} animated={false} + fixedDecimals={true} /> {isHighFee && } {editable && ( diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..e3a133721 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,258 @@ +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu" + +import { cn } from "@/lib/utils" +import { IconChevronRight, IconCheck } from "@tabler/icons-react" + +function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) { + return +} + +function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) { + return ( + + ) +} + +function ContextMenuTrigger({ + className, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + + ) +} + +function ContextMenuContent({ + className, + align = "start", + alignOffset = 4, + side = "right", + sideOffset = 0, + ...props +}: ContextMenuPrimitive.Popup.Props & + Pick< + ContextMenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) { + return ( + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: ContextMenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: ContextMenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: ContextMenuPrimitive.CheckboxItem.Props) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioGroup({ + ...props +}: ContextMenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: ContextMenuPrimitive.RadioItem.Props) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 809008d06..251cb7bd6 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -146,6 +146,7 @@ function DropdownMenuSubContent({ function DropdownMenuCheckboxItem({ className, children, + checked, ...props }: MenuPrimitive.CheckboxItem.Props) { return ( @@ -155,6 +156,7 @@ function DropdownMenuCheckboxItem({ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} + checked={checked} {...props} > ) { + return ( +
+ ) +} + +function ItemSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const itemVariants = cva( + "[a]:hover:bg-muted rounded-lg border text-sm w-full group/item focus-visible:border-ring focus-visible:ring-ring/50 flex items-center flex-wrap outline-none transition-colors duration-100 focus-visible:ring-[3px] [a]:transition-colors", + { + variants: { + variant: { + default: "border-transparent", + outline: "border-border", + muted: "bg-muted/50 border-transparent", + }, + size: { + default: "gap-2.5 px-3 py-2.5", + sm: "gap-2.5 px-3 py-2.5", + xs: "gap-2 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Item({ + className, + variant = "default", + size = "default", + render, + ...props +}: useRender.ComponentProps<"div"> & VariantProps) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn(itemVariants({ variant, size, className })), + }, + props + ), + render, + state: { + slot: "item", + variant, + size, + }, + }) +} + +const itemMediaVariants = cva( + "gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none", + { + variants: { + variant: { + default: "bg-transparent", + icon: "[&_svg:not([class*='size-'])]:size-4", + image: "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function ItemMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ItemContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

a:hover]:text-primary line-clamp-2 font-normal [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ) +} + +function ItemActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ) +} + +function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +} diff --git a/src/components/wallet/wallet-address-portfolio-view.tsx b/src/components/wallet/wallet-address-portfolio-view.tsx index ad950db19..4828ad0a5 100644 --- a/src/components/wallet/wallet-address-portfolio-view.tsx +++ b/src/components/wallet/wallet-address-portfolio-view.tsx @@ -4,7 +4,7 @@ import { TokenList } from '@/components/token/token-list' import { TransactionList } from '@/components/transaction/transaction-list' import { SwipeableTabs } from '@/components/layout/swipeable-tabs' import { ProviderFallbackWarning } from '@/components/common/provider-fallback-warning' -import type { TokenInfo, TokenItemContext } from '@/components/token/token-item' +import type { TokenInfo, TokenItemContext, TokenMenuItem } from '@/components/token/token-item' import type { TransactionInfo } from '@/components/transaction/transaction-item' import type { ChainType } from '@/stores' @@ -28,10 +28,12 @@ export interface WalletAddressPortfolioViewProps { renderTransactionFooter?: () => React.ReactNode /** Main asset symbol for the chain (used for renderActions context) */ mainAssetSymbol?: string - /** Render prop for token item actions (deprecated: use onTokenContextMenu) */ + /** Render prop for token item actions (deprecated: use tokenMenuItems) */ renderTokenActions?: (token: TokenInfo, context: TokenItemContext) => React.ReactNode - /** Context menu handler for token items (right-click, long-press, more button) */ + /** Context menu handler for token items (deprecated: use tokenMenuItems) */ onTokenContextMenu?: (event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => void + /** Menu items for token dropdown menu (recommended approach) */ + tokenMenuItems?: (token: TokenInfo, context: TokenItemContext) => TokenMenuItem[] className?: string testId?: string } @@ -54,6 +56,7 @@ export function WalletAddressPortfolioView({ mainAssetSymbol, renderTokenActions, onTokenContextMenu, + tokenMenuItems, className, testId = 'wallet-address-portfolio', }: WalletAddressPortfolioViewProps) { @@ -89,6 +92,7 @@ export function WalletAddressPortfolioView({ mainAssetSymbol={mainAssetSymbol} renderActions={renderTokenActions} onContextMenu={onTokenContextMenu} + menuItems={tokenMenuItems} testId={`${testId}-token-list`} />
diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 5ca07b75a..891f95e0e 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -98,6 +98,15 @@ export function SendPage() { decimals: chainConfig.decimals, }; }, [chainConfig, tokens, initialAssetType]); + + // useSend hook must be called before any code that references state/setAsset + const { state, setToAddress, setAmount, setAsset, setFee, goToConfirm, submit, submitWithTwoStepSecret, reset, canProceed } = useSend({ + initialAsset: initialAsset ?? undefined, + useMock: false, + walletId: currentWallet?.id, + fromAddress: currentChainAddress?.address, + chainConfig, + }); // Selected token for AssetSelector (convert from state.asset) const selectedToken = useMemo((): TokenInfo | null => { @@ -124,14 +133,6 @@ export function SendPage() { setAsset(asset); }, [chainConfig?.decimals, setAsset]); - const { state, setToAddress, setAmount, setAsset, setFee, goToConfirm, submit, submitWithTwoStepSecret, reset, canProceed } = useSend({ - initialAsset: initialAsset ?? undefined, - useMock: false, - walletId: currentWallet?.id, - fromAddress: currentChainAddress?.address, - chainConfig, - }); - useEffect(() => { if (!initialAsset) return; setAsset(initialAsset); diff --git a/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx index 40c467163..a04825116 100644 --- a/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx @@ -160,6 +160,7 @@ function MiniappDestroyConfirmJobContent() { size="xl" weight="bold" decimals={8} + fixedDecimals={true} />
diff --git a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx index 3c5f27711..b61c16399 100644 --- a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx @@ -158,6 +158,7 @@ function MiniappTransferConfirmJobContent() { size="xl" weight="bold" decimals={8} + fixedDecimals={true} />
diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index af267729d..fae2858ee 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useFlow } from "../../stackflow"; import { WalletCardCarousel } from "@/components/wallet/wallet-card-carousel"; @@ -7,16 +7,10 @@ import { LoadingSpinner } from "@/components/common/loading-spinner"; import { MigrationRequiredView } from "@/components/common/migration-required-view"; import { GradientButton } from "@/components/common/gradient-button"; import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; import { useWalletTheme } from "@/hooks/useWalletTheme"; import { useClipboard, useToast, useHaptics } from "@/services"; import { useBalanceQuery, useTransactionHistoryQuery } from "@/queries"; -import type { TokenInfo, TokenItemContext } from "@/components/token/token-item"; +import type { TokenInfo, TokenItemContext, TokenMenuItem } from "@/components/token/token-item"; import { IconPlus, IconSend, @@ -162,35 +156,31 @@ export function WalletTab() { [push] ); - // 资产操作菜单状态 - const [actionSheetOpen, setActionSheetOpen] = useState(false); - const [selectedToken, setSelectedToken] = useState<{ token: TokenInfo; context: TokenItemContext } | null>(null); - - // 资产操作菜单触发 - const handleTokenContextMenu = useCallback( - (_event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => { - haptics.impact("light"); - setSelectedToken({ token, context }); - setActionSheetOpen(true); - }, - [haptics] + // 资产操作菜单项生成器 + const getTokenMenuItems = useCallback( + (token: TokenInfo, context: TokenItemContext): TokenMenuItem[] => [ + { + label: t("home:wallet.transfer"), + icon: , + onClick: () => { + haptics.impact("light"); + push("SendActivity", { assetType: token.symbol }); + }, + }, + { + label: t("home:wallet.destroy"), + icon: , + onClick: () => { + haptics.impact("light"); + push("DestroyActivity", { assetType: token.symbol, assetLocked: "true" }); + }, + variant: "destructive", + show: context.canDestroy, + }, + ], + [haptics, push, t] ); - // 资产操作菜单项点击 - const handleTransfer = useCallback(() => { - if (selectedToken) { - setActionSheetOpen(false); - push("SendActivity", { assetType: selectedToken.token.symbol }); - } - }, [selectedToken, push]); - - const handleDestroy = useCallback(() => { - if (selectedToken) { - setActionSheetOpen(false); - push("DestroyActivity", { assetType: selectedToken.token.symbol, assetLocked: "true" }); - } - }, [selectedToken, push]); - // 需要迁移数据库 if (migrationRequired) { return ; @@ -288,7 +278,7 @@ export function WalletTab() { }} onTransactionClick={handleTransactionClick} mainAssetSymbol={mainAssetSymbol} - onTokenContextMenu={handleTokenContextMenu} + tokenMenuItems={getTokenMenuItems} renderTransactionFooter={() => ( - {selectedToken?.context.canDestroy && ( - - )} -
- -
); }