Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
viewportFit: 'cover',
};

export default function RootLayout({
Expand Down
30 changes: 21 additions & 9 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,27 @@ export default function Home() {
const [tokenOut, setTokenOut] = useState<Token | null>(null);

return (
<div className="flex flex-col flex-1 items-center bg-zinc-50 dark:bg-black min-h-screen px-4 py-6 sm:p-8 overflow-x-hidden">
<div className="w-full max-w-md flex flex-col gap-4">
<PriceChart
tokenA={tokenIn?.id ?? null}
tokenB={tokenOut?.id ?? null}
tokenASymbol={tokenIn?.symbol}
tokenBSymbol={tokenOut?.symbol}
/>
<SwapWidget wallet={wallet} onTokenInChange={setTokenIn} onTokenOutChange={setTokenOut} />
<div className="flex flex-col flex-1 items-center bg-zinc-50 dark:bg-black min-h-screen px-4 py-6 sm:px-6 sm:py-8 overflow-x-hidden">
{/*
* 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.
*/}
<div className="w-full max-w-5xl flex flex-col md:flex-row md:items-start gap-4 md:gap-6">
{/* Chart — full width on mobile, fills remaining space on md+ */}
<div className="w-full md:flex-1 min-w-0">
<PriceChart
tokenA={tokenIn?.id ?? null}
tokenB={tokenOut?.id ?? null}
tokenASymbol={tokenIn?.symbol}
tokenBSymbol={tokenOut?.symbol}
/>
</div>

{/* Swap widget — full width on mobile, fixed max-width column on md+ */}
<div className="w-full md:w-auto md:shrink-0">
<SwapWidget wallet={wallet} onTokenInChange={setTokenIn} onTokenOutChange={setTokenOut} />
</div>
</div>
</div>
);
Expand Down
137 changes: 137 additions & 0 deletions apps/web/components/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<a href={href} onClick={onClick} className={className}>
{children}
</a>
),
}));

// WalletButton has its own context — stub it out for layout-only tests
vi.mock('./WalletButton', () => ({
WalletButton: () => <button type="button">Connect wallet</button>,
}));

function renderNavbar() {
return render(<Navbar />);
}

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]');
});
});
});
90 changes: 74 additions & 16 deletions apps/web/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<nav className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-zinc-200 bg-white/80 px-6 backdrop-blur dark:border-zinc-800 dark:bg-black/80">
<div className="flex items-center gap-6">
<Link
href="/"
className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white hover:opacity-80 transition-opacity"
>
Swyft
</Link>
<Link
href="/history"
className="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
>
History
</Link>
<nav
className="sticky top-0 z-40 border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-black/80"
aria-label="Main navigation"
>
{/* Primary bar */}
<div className="flex h-16 items-center justify-between px-4 sm:px-6">
{/* Left: logo + desktop links */}
<div className="flex items-center gap-6">
<Link
href="/"
className="text-lg font-bold tracking-tight text-zinc-900 dark:text-white hover:opacity-80 transition-opacity"
>
Swyft
</Link>
{/* Desktop nav links — hidden on mobile */}
<div className="hidden sm:flex items-center gap-4">
{NAV_LINKS.map(({ href, label }) => (
<Link
key={href}
href={href}
className="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
>
{label}
</Link>
))}
</div>
</div>

{/* Right: wallet + hamburger */}
<div className="flex items-center gap-3">
<WalletButton />
{/* Hamburger — visible only on mobile */}
<button
type="button"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={menuOpen}
aria-controls="mobile-nav"
onClick={() => setMenuOpen((o) => !o)}
className="flex sm:hidden h-10 w-10 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
{menuOpen ? (
/* X icon */
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
/* Hamburger icon */
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
<WalletButton />

{/* Mobile dropdown menu */}
{menuOpen && (
<div
id="mobile-nav"
className="sm:hidden border-t border-zinc-100 dark:border-zinc-800 bg-white dark:bg-black px-4 py-3 flex flex-col gap-1"
>
{NAV_LINKS.map(({ href, label }) => (
<Link
key={href}
href={href}
onClick={() => setMenuOpen(false)}
className="min-h-[44px] flex items-center rounded-lg px-3 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800 transition-colors"
>
{label}
</Link>
))}
</div>
)}
</nav>
);
}
4 changes: 2 additions & 2 deletions apps/web/components/SwapConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ export function SwapConfirmModal({
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 backdrop-blur-sm px-4 pb-4 sm:pb-0"
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 backdrop-blur-sm px-4 pb-[env(safe-area-inset-bottom,1rem)] sm:pb-0"
role="dialog"
aria-modal="true"
aria-label="Confirm swap"
>
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-900 sm:mx-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-100 px-5 py-4 dark:border-zinc-800">
<h2 className="text-base font-semibold text-zinc-900 dark:text-white">
Expand Down
8 changes: 4 additions & 4 deletions apps/web/components/SwapSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,21 @@ export function SwapSettings() {
const [custom, setCustom] = useState('');

return (
<div className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-4 w-full max-w-sm shadow-sm">
<div className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-4 w-full shadow-sm">
<h2 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100 mb-3">Swap Settings</h2>

{/* Slippage */}
<div className="mb-3">
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-1.5">Slippage tolerance</p>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{SLIPPAGE_PRESETS.map((p) => (
<button
key={p}
onClick={() => {
setSlippage(p);
setCustom('');
}}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
className={`min-h-[40px] px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
slippage === p && !custom
? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700'
Expand All @@ -44,7 +44,7 @@ export function SwapSettings() {
setCustom(e.target.value);
setSlippage(e.target.value);
}}
className="w-20 px-2 py-1 rounded-lg text-sm border border-zinc-200 dark:border-zinc-700 bg-transparent text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-zinc-400"
className="min-h-[40px] w-20 px-2 py-1 rounded-lg text-sm border border-zinc-200 dark:border-zinc-700 bg-transparent text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-zinc-400"
/>
</div>
</div>
Expand Down
Loading
Loading