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
40 changes: 22 additions & 18 deletions app/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useWallet } from "@/app/context/WalletContext";
import { useIsAdmin } from "@/app/hooks/useIsAdmin";
import { SUPPORTED_WALLETS } from "@/app/context/WalletContext";
import Link from "next/link";
import WalletBadge from "./WalletBadge";

export default function Navbar() {
const {
Expand All @@ -16,8 +17,6 @@ export default function Navbar() {
} = useWallet();
const { isAdminUser } = useIsAdmin(address);

const short = (addr: string) => `${addr.slice(0, 4)}...${addr.slice(-4)}`;

const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 rounded";

Expand All @@ -31,18 +30,30 @@ export default function Navbar() {
⚠️ {networkMismatchMessage}
</div>
)}
{/*
* `relative z-10` establishes a stacking context so the nav sits above
* non-overlay page content on mobile. Full-screen overlays (LedgerLoaderOverlay,
* WalletLoaderOverlay) intentionally use z-50 and will still cover the nav
* correctly during wallet operations.
*/}
<nav
aria-label="Primary"
className="border-b border-gray-800 bg-gray-950 px-6 py-4 flex items-center justify-between"
className="relative z-10 border-b border-gray-800 bg-gray-950 px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 flex-wrap"
>
<Link
href="/"
aria-label="Escrow home"
className={`text-xl font-bold text-white tracking-tight ${focusRing}`}
className={`text-xl font-bold text-white tracking-tight shrink-0 ${focusRing}`}
>
<span aria-hidden="true">🔐</span> Escrow
</Link>
<div className="flex items-center gap-4">
{/*
* `flex-wrap` allows nav items to wrap to a second line on narrow
* viewports (e.g. iPhone SE 375px wide) instead of overflowing
* horizontally and pushing the wallet badge out of the clickable area.
* `min-w-0` prevents flex children from refusing to shrink.
*/}
<div className="flex items-center gap-2 sm:gap-4 flex-wrap min-w-0">
{address ? (
<>
<Link
Expand All @@ -65,19 +76,12 @@ export default function Navbar() {
Admin
</Link>
)}
<span
role="status"
className="text-xs sm:text-sm text-gray-300 font-mono bg-gray-800 px-2 py-0.5 sm:px-3 sm:py-1 rounded-full transition-colors duration-200"
aria-label={`Connected wallet ${address}`}
>
{short(address)}
</span>
<button
onClick={disconnect}
className={`bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium px-4 py-2 rounded-lg transition ${focusRing}`}
>
Disconnect
</button>
<WalletBadge
address={address}
status="connected"
onDisconnect={disconnect}
className="shrink-0"
/>
</>
) : (
<>
Expand Down
209 changes: 209 additions & 0 deletions app/components/__tests__/WalletBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* WalletBadge – mobile viewport overlay / clickability tests
*
* Issue #259: wallet_badge interactive elements must remain clickable at
* mobile viewport heights and must not be obscured by stacking-context issues.
*
* jsdom does not do real CSS layout or z-index painting, so we verify:
* - the interactive element is rendered and present in the DOM at any viewport
* - pointer-events are not explicitly disabled on the element (style check)
* - click handlers fire correctly at a simulated mobile viewport size
* - desktop viewport rendering is unchanged (regression)
*
* The viewport height is simulated by resizing window.innerHeight before
* rendering, matching common culprit heights for mobile "short viewport" bugs
* (iPhone SE: 568px, iPhone 8: 667px).
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import WalletBadge from "../WalletBadge";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: width,
});
Object.defineProperty(window, "innerHeight", {
writable: true,
configurable: true,
value: height,
});
}

const MOBILE_VIEWPORTS = [
{ label: "iPhone SE (375×568)", width: 375, height: 568 },
{ label: "iPhone 8 (375×667)", width: 375, height: 667 },
{ label: "iPhone 8 Plus (414×736)", width: 414, height: 736 },
] as const;

const TEST_ADDRESS = "GBHK5YMW4RJL5Q3Z6GXPRQ7GQSFN7W5Y3Y3K7H5T6L7M8N9P0Q1R2S3";

// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------

describe("WalletBadge – mobile viewport overlay/clickability (Issue #259)", () => {
let originalInnerWidth: number;
let originalInnerHeight: number;

beforeEach(() => {
originalInnerWidth = window.innerWidth;
originalInnerHeight = window.innerHeight;
});

afterEach(() => {
setViewport(originalInnerWidth, originalInnerHeight);
});

// ── 1. Interactive element present + pointer-events not disabled ──────────

describe("interactive element presence at mobile viewport heights", () => {
MOBILE_VIEWPORTS.forEach(({ label, width, height }) => {
it(`disconnect button is in the DOM at ${label}`, () => {
setViewport(width, height);
const onDisconnect = vi.fn();
render(
<WalletBadge
address={TEST_ADDRESS}
status="connected"
onDisconnect={onDisconnect}
/>
);
const btn = screen.getByRole("button", { name: /disconnect wallet/i });
expect(btn).toBeInTheDocument();
});

it(`disconnect button does not have pointer-events: none at ${label}`, () => {
setViewport(width, height);
const onDisconnect = vi.fn();
render(
<WalletBadge
address={TEST_ADDRESS}
status="connected"
onDisconnect={onDisconnect}
/>
);
const btn = screen.getByRole("button", { name: /disconnect wallet/i });
// jsdom computes inline styles; Tailwind class-based styles are not
// applied by jsdom, but we can assert no inline override exists.
expect(btn).not.toHaveStyle({ pointerEvents: "none" });
});
});
});

// ── 2. Click handler fires at mobile viewport heights ────────────────────

describe("click handler fires at mobile viewport heights", () => {
MOBILE_VIEWPORTS.forEach(({ label, width, height }) => {
it(`onDisconnect fires when disconnect button is clicked at ${label}`, () => {
setViewport(width, height);
const onDisconnect = vi.fn();
render(
<WalletBadge
address={TEST_ADDRESS}
status="connected"
onDisconnect={onDisconnect}
/>
);
const btn = screen.getByRole("button", { name: /disconnect wallet/i });
fireEvent.click(btn);
expect(onDisconnect).toHaveBeenCalledTimes(1);
});
});
});

// ── 3. Desktop regression – existing behavior unchanged ──────────────────

describe("desktop viewport rendering (regression)", () => {
beforeEach(() => {
// Standard desktop: 1280×800
setViewport(1280, 800);
});

it("renders the wallet-badge element", () => {
render(
<WalletBadge address={TEST_ADDRESS} status="connected" />
);
expect(screen.getByTestId("wallet-badge")).toBeInTheDocument();
});

it("shows truncated address", () => {
render(
<WalletBadge address={TEST_ADDRESS} status="connected" />
);
expect(screen.getByTestId("wallet-badge")).toHaveTextContent("GBHK...R2S3");
});

it("renders disconnect button when onDisconnect is provided", () => {
const onDisconnect = vi.fn();
render(
<WalletBadge
address={TEST_ADDRESS}
status="connected"
onDisconnect={onDisconnect}
/>
);
expect(
screen.getByRole("button", { name: /disconnect wallet/i })
).toBeInTheDocument();
});

it("does not render disconnect button when onDisconnect is omitted", () => {
render(
<WalletBadge address={TEST_ADDRESS} status="connected" />
);
expect(
screen.queryByRole("button", { name: /disconnect wallet/i })
).not.toBeInTheDocument();
});

it("onDisconnect fires on click at desktop size", () => {
const onDisconnect = vi.fn();
render(
<WalletBadge
address={TEST_ADDRESS}
status="connected"
onDisconnect={onDisconnect}
/>
);
fireEvent.click(screen.getByRole("button", { name: /disconnect wallet/i }));
expect(onDisconnect).toHaveBeenCalledTimes(1);
});

it("renders loading state", () => {
render(<WalletBadge status="loading" />);
const badge = screen.getByTestId("wallet-badge");
expect(badge).toHaveAttribute("data-status", "loading");
expect(badge).toHaveTextContent(/connecting/i);
});

it("renders error state with message", () => {
render(
<WalletBadge status="error" errorMessage="Failed to connect" />
);
const badge = screen.getByTestId("wallet-badge");
expect(badge).toHaveAttribute("data-status", "error");
expect(badge).toHaveTextContent("Failed to connect");
});

it("renders error state with fallback text when no errorMessage", () => {
render(<WalletBadge status="error" />);
expect(screen.getByTestId("wallet-badge")).toHaveTextContent(
/wallet error/i
);
});

it("renders disconnected state", () => {
render(<WalletBadge status="disconnected" />);
const badge = screen.getByTestId("wallet-badge");
expect(badge).toHaveAttribute("data-status", "disconnected");
expect(badge).toHaveTextContent(/no wallet/i);
});
});
});
Loading