From 2df4d93831dd24bcdfd88da97670574d8eaa6bed Mon Sep 17 00:00:00 2001 From: Gideon Odekina Date: Mon, 29 Jun 2026 20:43:57 +0100 Subject: [PATCH] feat(web): mobile responsive swap card layout (#426) Implements mobile-first responsive layout across all swap UI components. Changes: - page.tsx: single-column on mobile, side-by-side chart+widget on md+ (max-w-5xl) - SwapWidget: w-full mobile / md:w-[448px] desktop, responsive px, touch-friendly - SwapWidget: TokenPickerButton adds backdrop overlay for mobile tap-to-close - SwapWidget: aria-haspopup + aria-expanded on token picker buttons - SwapWidget: min-h-[44px] on dropdown options, min-h-[52px] CTA on mobile - SwapCard: removed max-w-sm constraint, rounded-2xl, min-h-[52px] button - SwapCard: settings button gets h-9 w-9 touch target + aria-expanded - SwapSettings: flex-wrap slippage buttons, removed max-w-sm, min-h-[40px] btns - SwapConfirmModal: safe-area-inset-bottom for iOS notch, sm:mx-auto centering - Navbar: mobile hamburger menu with aria-expanded, aria-controls, aria-label - Navbar: desktop links hidden on mobile (hidden sm:flex), mobile dropdown panel - Navbar: mobile nav links have min-h-[44px] touch targets - layout.tsx: viewportFit=cover for iOS safe-area support - Add Navbar.test.tsx (13 tests: hamburger menu, a11y, desktop rendering) - Add SwapWidget.test.tsx (26 tests: layout, token picker, states, touch targets) --- apps/web/app/layout.tsx | 1 + apps/web/app/page.tsx | 30 ++- apps/web/components/Navbar.test.tsx | 137 ++++++++++ apps/web/components/Navbar.tsx | 90 +++++-- apps/web/components/SwapCard.tsx | 34 +-- apps/web/components/SwapConfirmModal.tsx | 4 +- apps/web/components/SwapSettings.tsx | 8 +- apps/web/components/SwapWidget.test.tsx | 316 +++++++++++++++++++++++ apps/web/components/SwapWidget.tsx | 110 ++++---- 9 files changed, 634 insertions(+), 96 deletions(-) create mode 100644 apps/web/components/Navbar.test.tsx create mode 100644 apps/web/components/SwapWidget.test.tsx diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 87c9e42..33c5c81 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -25,6 +25,7 @@ export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 1, + viewportFit: 'cover', }; export default function RootLayout({ diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 766b925..cff627a 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -12,15 +12,27 @@ export default function Home() { const [tokenOut, setTokenOut] = useState(null); return ( -
-
- - +
+ {/* + * Mobile (< md): single column — chart stacked above swap widget, full width. + * Tablet+ (≥ md): two-column row — chart on the left, swap widget on the right. + * The wrapper is capped at max-w-5xl so it doesn't stretch too wide on large monitors. + */} +
+ {/* Chart — full width on mobile, fills remaining space on md+ */} +
+ +
+ + {/* Swap widget — full width on mobile, fixed max-width column on md+ */} +
+ +
); diff --git a/apps/web/components/Navbar.test.tsx b/apps/web/components/Navbar.test.tsx new file mode 100644 index 0000000..0796c2b --- /dev/null +++ b/apps/web/components/Navbar.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Navbar } from './Navbar'; + +// Mock Next.js Link so we don't need a router context in tests +vi.mock('next/link', () => ({ + default: ({ + href, + children, + onClick, + className, + }: { + href: string; + children: React.ReactNode; + onClick?: () => void; + className?: string; + }) => ( + + {children} + + ), +})); + +// WalletButton has its own context — stub it out for layout-only tests +vi.mock('./WalletButton', () => ({ + WalletButton: () => , +})); + +function renderNavbar() { + return render(); +} + +describe('Navbar', () => { + describe('desktop rendering', () => { + it('renders the Swyft logo link', () => { + renderNavbar(); + expect(screen.getByRole('link', { name: 'Swyft' })).toBeInTheDocument(); + }); + + it('renders nav links for Swap, History, Portfolio', () => { + renderNavbar(); + expect(screen.getByRole('link', { name: 'Swap' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'History' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Portfolio' })).toBeInTheDocument(); + }); + + it('renders the wallet button', () => { + renderNavbar(); + expect(screen.getByRole('button', { name: 'Connect wallet' })).toBeInTheDocument(); + }); + }); + + describe('mobile hamburger menu', () => { + it('renders the hamburger button', () => { + renderNavbar(); + expect(screen.getByRole('button', { name: 'Open menu' })).toBeInTheDocument(); + }); + + it('mobile nav is hidden by default', () => { + renderNavbar(); + expect(screen.queryByRole('navigation', { name: 'mobile-nav' })).not.toBeInTheDocument(); + // The mobile nav links are in the dropdown which is not rendered initially + const mobileLinks = screen.queryAllByRole('link', { name: 'Swap' }); + // Only the desktop link should be visible (one instance) + expect(mobileLinks).toHaveLength(1); + }); + + it('opens mobile menu when hamburger is clicked', () => { + renderNavbar(); + const hamburger = screen.getByRole('button', { name: 'Open menu' }); + fireEvent.click(hamburger); + // After opening, the mobile dropdown adds more link instances + const swapLinks = screen.getAllByRole('link', { name: 'Swap' }); + expect(swapLinks.length).toBeGreaterThan(1); + }); + + it('hamburger button aria-expanded is false when closed', () => { + renderNavbar(); + const hamburger = screen.getByRole('button', { name: 'Open menu' }); + expect(hamburger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('hamburger button aria-expanded is true when open', () => { + renderNavbar(); + const hamburger = screen.getByRole('button', { name: 'Open menu' }); + fireEvent.click(hamburger); + expect(screen.getByRole('button', { name: 'Close menu' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); + + it('closes mobile menu when hamburger is clicked again', () => { + renderNavbar(); + const hamburger = screen.getByRole('button', { name: 'Open menu' }); + fireEvent.click(hamburger); + const closeBtn = screen.getByRole('button', { name: 'Close menu' }); + fireEvent.click(closeBtn); + // Back to closed state — only desktop links remain + expect(screen.getAllByRole('link', { name: 'Swap' })).toHaveLength(1); + }); + + it('closes mobile menu when a nav link is clicked', () => { + renderNavbar(); + const hamburger = screen.getByRole('button', { name: 'Open menu' }); + fireEvent.click(hamburger); + // Click the Swap link inside the mobile dropdown (second occurrence) + const swapLinks = screen.getAllByRole('link', { name: 'Swap' }); + fireEvent.click(swapLinks[swapLinks.length - 1]); + // Menu should now be closed — only the desktop link remains + expect(screen.getAllByRole('link', { name: 'Swap' })).toHaveLength(1); + }); + + it('hamburger button has accessible label', () => { + renderNavbar(); + const btn = screen.getByRole('button', { name: 'Open menu' }); + expect(btn).toHaveAttribute('aria-label', 'Open menu'); + expect(btn).toHaveAttribute('aria-controls', 'mobile-nav'); + }); + }); + + describe('accessibility', () => { + it('nav element has accessible label', () => { + renderNavbar(); + expect(screen.getByRole('navigation', { name: 'Main navigation' })).toBeInTheDocument(); + }); + + it('mobile menu links have minimum touch target height class', () => { + renderNavbar(); + fireEvent.click(screen.getByRole('button', { name: 'Open menu' })); + // All mobile menu links should have min-h-[44px] for touch targets + const mobileSwapLinks = screen.getAllByRole('link', { name: 'Swap' }); + const mobileLink = mobileSwapLinks[mobileSwapLinks.length - 1]; + expect(mobileLink.className).toContain('min-h-[44px]'); + }); + }); +}); diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx index aa4f2ea..d656e01 100644 --- a/apps/web/components/Navbar.tsx +++ b/apps/web/components/Navbar.tsx @@ -6,29 +6,87 @@ import { WalletButton } from '@/components/WalletButton'; const NAV_LINKS = [ { href: '/', label: 'Swap' }, + { href: '/history', label: 'History' }, { href: '/portfolio', label: 'Portfolio' }, ]; export function Navbar() { - const [open, setOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); return ( -