@@ -69,40 +70,48 @@ const TxModal = dynamic(
export default function Home() {
const { account, isConnected, connect, disconnect } = useWeb3Auth();
+ const headerContent = (
+
+
Utility Protocol
+
+ {/* Mobile header - simplified */}
+
+
+
+
+ );
+
+ const footerContent = (
+ <>
+ © {new Date().getFullYear()} Utility Protocol. All rights reserved.
+ >
+ );
+
return (
-
-
-
-
- Utility Protocol
-
-
-
-
-
-
+
+
@@ -152,11 +161,7 @@ export default function Home() {
balance=""
/>
-
-
-
-
+
+
);
}
diff --git a/src/components/layout/MobileBottomNav.tsx b/src/components/layout/MobileBottomNav.tsx
new file mode 100644
index 0000000..04a42c8
--- /dev/null
+++ b/src/components/layout/MobileBottomNav.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+interface NavItem {
+ label: string;
+ href: string;
+ icon: string;
+ ariaLabel: string;
+}
+
+const NAV_ITEMS: NavItem[] = [
+ {
+ label: "Dashboard",
+ href: "/",
+ icon: "π",
+ ariaLabel: "Dashboard navigation item",
+ },
+ {
+ label: "Fleet",
+ href: "/fleet",
+ icon: "π",
+ ariaLabel: "Fleet navigation item",
+ },
+ {
+ label: "Map",
+ href: "/map",
+ icon: "πΊοΈ",
+ ariaLabel: "Map navigation item",
+ },
+ {
+ label: "Settings",
+ href: "/settings",
+ icon: "βοΈ",
+ ariaLabel: "Settings navigation item",
+ },
+];
+
+export interface MobileBottomNavProps {
+ className?: string;
+}
+
+/**
+ * Mobile bottom navigation component
+ * Provides touch-optimized navigation for mobile devices (< 768px)
+ * Features: 44px minimum tap targets, smooth animations
+ */
+export const MobileBottomNav: React.FC
= ({
+ className = "",
+}) => {
+ const pathname = usePathname();
+
+ return (
+
+ );
+};
diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx
new file mode 100644
index 0000000..0c585ac
--- /dev/null
+++ b/src/components/layout/MobileMenu.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+interface MenuItem {
+ label: string;
+ href: string;
+ ariaLabel: string;
+}
+
+const MENU_ITEMS: MenuItem[] = [
+ {
+ label: "Dashboard",
+ href: "/",
+ ariaLabel: "Dashboard menu item",
+ },
+ {
+ label: "Fleet",
+ href: "/fleet",
+ ariaLabel: "Fleet menu item",
+ },
+ {
+ label: "Map",
+ href: "/map",
+ ariaLabel: "Map menu item",
+ },
+ {
+ label: "Settings",
+ href: "/settings",
+ ariaLabel: "Settings menu item",
+ },
+ {
+ label: "Documentation",
+ href: "/docs",
+ ariaLabel: "Documentation menu item",
+ },
+];
+
+export interface MobileMenuProps {
+ isOpen: boolean;
+ onClose: () => void;
+ className?: string;
+}
+
+/**
+ * Mobile collapsible menu component
+ * Features: Smooth slide animation, backdrop overlay, touch-optimized
+ * 44px+ minimum tap targets for accessibility
+ */
+export const MobileMenu: React.FC = ({
+ isOpen,
+ onClose,
+ className = "",
+}) => {
+ const pathname = usePathname();
+ const menuRef = useRef(null);
+ const backdropRef = useRef(null);
+
+ // Handle escape key to close menu
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isOpen) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ window.addEventListener("keydown", handleEscape);
+ // Prevent body scroll when menu is open
+ document.body.style.overflow = "hidden";
+ }
+
+ return () => {
+ window.removeEventListener("keydown", handleEscape);
+ document.body.style.overflow = "unset";
+ };
+ }, [isOpen, onClose]);
+
+ // Handle click outside to close menu
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ backdropRef.current &&
+ e.target === backdropRef.current &&
+ isOpen
+ ) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("click", handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [isOpen, onClose]);
+
+ return (
+ <>
+ {/* Backdrop overlay */}
+
+
+ {/* Menu panel */}
+
+
+ {/* Header with close button */}
+
+
Menu
+
+
+
+ {/* Menu items */}
+
+
+ {MENU_ITEMS.map((item) => {
+ const isActive = pathname === item.href;
+
+ return (
+ -
+
+ {item.label}
+
+
+ );
+ })}
+
+
+
+ {/* Footer info */}
+
+
Utility Protocol v1.0
+
+
+
+ >
+ );
+};
diff --git a/src/components/layout/ResponsiveLayout.tsx b/src/components/layout/ResponsiveLayout.tsx
new file mode 100644
index 0000000..ad6db63
--- /dev/null
+++ b/src/components/layout/ResponsiveLayout.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState, ReactNode } from "react";
+import { MobileBottomNav } from "./MobileBottomNav";
+import { MobileMenu } from "./MobileMenu";
+import { useSwipeGesture } from "@/hooks/useSwipeGesture";
+
+interface ResponsiveLayoutProps {
+ children: ReactNode;
+ header?: ReactNode;
+ footer?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Responsive layout component that handles mobile navigation
+ * Features:
+ * - Bottom navigation for mobile (<768px)
+ * - Swipe gesture support (right swipe opens menu, left swipe closes)
+ * - Collapsible mobile menu
+ * - Smooth animations and transitions
+ */
+export const ResponsiveLayout: React.FC = ({
+ children,
+ header,
+ footer,
+ className = "",
+}) => {
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+
+ const swipeRef = useSwipeGesture({
+ threshold: 50,
+ onSwipeRight: () => {
+ setIsMobileMenuOpen(true);
+ },
+ onSwipeLeft: () => {
+ setIsMobileMenuOpen(false);
+ },
+ });
+
+ const handleMenuToggle = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ const handleMenuClose = () => {
+ setIsMobileMenuOpen(false);
+ };
+
+ return (
+
+ {/* Header with mobile menu toggle */}
+ {header && (
+
+ )}
+
+ {/* Main content with bottom padding for mobile nav */}
+
+ {children}
+
+
+ {/* Footer */}
+ {footer && (
+
+ )}
+
+ {/* Mobile bottom navigation */}
+
+
+ {/* Mobile menu with swipe gesture support */}
+
+
+ );
+};
diff --git a/src/hooks/useSwipeGesture.ts b/src/hooks/useSwipeGesture.ts
new file mode 100644
index 0000000..e9c92e1
--- /dev/null
+++ b/src/hooks/useSwipeGesture.ts
@@ -0,0 +1,91 @@
+import { useRef, useCallback, useEffect } from "react";
+
+interface SwipeOptions {
+ threshold?: number;
+ onSwipeLeft?: () => void;
+ onSwipeRight?: () => void;
+ onSwipeUp?: () => void;
+ onSwipeDown?: () => void;
+}
+
+interface TouchPosition {
+ x: number;
+ y: number;
+}
+
+/**
+ * Custom hook to detect swipe gestures on touch devices
+ * @param options Configuration for swipe detection
+ * @returns Ref to attach to the element that should detect swipes
+ */
+export const useSwipeGesture = ({
+ threshold = 50,
+ onSwipeLeft,
+ onSwipeRight,
+ onSwipeUp,
+ onSwipeDown,
+}: SwipeOptions) => {
+ const elementRef = useRef(null);
+ const touchStartRef = useRef({ x: 0, y: 0 });
+ const touchEndRef = useRef({ x: 0, y: 0 });
+
+ const handleSwipe = useCallback(() => {
+ const distance = {
+ x: touchEndRef.current.x - touchStartRef.current.x,
+ y: touchEndRef.current.y - touchStartRef.current.y,
+ };
+
+ const absDistX = Math.abs(distance.x);
+ const absDistY = Math.abs(distance.y);
+
+ // Only trigger if swipe distance exceeds threshold
+ // and the primary direction is more pronounced than the secondary
+ if (absDistX > threshold && absDistX > absDistY) {
+ if (distance.x > 0) {
+ onSwipeRight?.();
+ } else {
+ onSwipeLeft?.();
+ }
+ } else if (absDistY > threshold && absDistY > absDistX) {
+ if (distance.y > 0) {
+ onSwipeDown?.();
+ } else {
+ onSwipeUp?.();
+ }
+ }
+ }, [threshold, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown]);
+
+ useEffect(() => {
+ const element = elementRef.current;
+ if (!element) return;
+
+ const handleTouchStart = (e: TouchEvent) => {
+ if (e.changedTouches && e.changedTouches.length > 0) {
+ touchStartRef.current = {
+ x: e.changedTouches[0].clientX,
+ y: e.changedTouches[0].clientY,
+ };
+ }
+ };
+
+ const handleTouchEnd = (e: TouchEvent) => {
+ if (e.changedTouches && e.changedTouches.length > 0) {
+ touchEndRef.current = {
+ x: e.changedTouches[0].clientX,
+ y: e.changedTouches[0].clientY,
+ };
+ handleSwipe();
+ }
+ };
+
+ element.addEventListener("touchstart", handleTouchStart, { passive: true });
+ element.addEventListener("touchend", handleTouchEnd, { passive: true });
+
+ return () => {
+ element.removeEventListener("touchstart", handleTouchStart);
+ element.removeEventListener("touchend", handleTouchEnd);
+ };
+ }, [handleSwipe]);
+
+ return elementRef;
+};
diff --git a/src/styles/mobile-navigation.css b/src/styles/mobile-navigation.css
new file mode 100644
index 0000000..9028876
--- /dev/null
+++ b/src/styles/mobile-navigation.css
@@ -0,0 +1,118 @@
+/* Mobile Navigation Responsive Styles */
+
+/* Touch target minimum size (44x44px for accessibility) */
+@media (max-width: 768px) {
+ /* Bottom navigation - ensure adequate spacing for touch targets */
+ [role="navigation"] button,
+ [role="navigation"] a {
+ min-height: 2.75rem; /* 44px */
+ min-width: 2.75rem; /* 44px */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ /* Mobile menu close button and links - ensure 44px tap targets */
+ [role="dialog"] button,
+ [role="dialog"] a {
+ min-height: 3rem; /* 48px for extra comfort */
+ min-width: 3rem;
+ }
+
+ /* Prevent tap delay on touch devices */
+ button,
+ a {
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+ }
+
+ /* Smooth transitions for mobile interactions */
+ * {
+ --transition-duration: 200ms;
+ --transition-timing: ease-out;
+ }
+
+ /* Safe area insets for notched devices */
+ body {
+ padding-left: max(0px, env(safe-area-inset-left));
+ padding-right: max(0px, env(safe-area-inset-right));
+ padding-bottom: max(0px, env(safe-area-inset-bottom));
+ }
+
+ /* Prevent scroll bounce on iOS */
+ body {
+ overscroll-behavior: none;
+ }
+
+ /* Ensure main content doesn't get hidden under bottom nav */
+ main {
+ padding-bottom: max(5rem, env(safe-area-inset-bottom));
+ }
+}
+
+/* Landscape mobile mode */
+@media (max-width: 768px) and (max-height: 500px) {
+ /* Reduce padding in landscape to maximize visible content */
+ main {
+ padding-bottom: max(1rem, env(safe-area-inset-bottom));
+ }
+
+ /* Compact bottom navigation in landscape */
+ [role="navigation"] {
+ height: 3rem;
+ }
+
+ [role="navigation"] span {
+ display: none;
+ }
+}
+
+/* Tablet and above - hide mobile navigation */
+@media (min-width: 769px) {
+ /* Hide mobile-only navigation elements */
+ .md\:hidden {
+ display: none;
+ }
+
+ /* Desktop layout adjustments */
+ main {
+ padding-bottom: 0;
+ }
+}
+
+/* Accessibility: Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* High contrast mode support */
+@media (prefers-contrast: more) {
+ [role="navigation"],
+ [role="dialog"] {
+ border-width: 2px;
+ }
+
+ button:focus,
+ a:focus {
+ outline-width: 3px;
+ }
+}
+
+/* Touch device optimizations */
+@media (hover: none) and (pointer: coarse) {
+ /* Remove hover effects on touch devices, use active/focus instead */
+ button:hover,
+ a:hover {
+ background-color: initial;
+ }
+
+ button:active,
+ a:active {
+ opacity: 0.8;
+ transform: scale(0.98);
+ }
+}
diff --git a/tests/components/layout/MobileBottomNav.test.tsx b/tests/components/layout/MobileBottomNav.test.tsx
new file mode 100644
index 0000000..42cac6c
--- /dev/null
+++ b/tests/components/layout/MobileBottomNav.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { MobileBottomNav } from "@/components/layout/MobileBottomNav";
+
+// Mock Next.js router
+vi.mock("next/navigation", () => ({
+ usePathname: vi.fn(() => "/"),
+}));
+
+describe("MobileBottomNav", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render bottom navigation", () => {
+ render();
+ const nav = screen.getByRole("navigation", { name: /mobile navigation/i });
+ expect(nav).toBeInTheDocument();
+ });
+
+ it("should render all navigation items", () => {
+ render();
+ expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
+ expect(screen.getByText(/fleet/i)).toBeInTheDocument();
+ expect(screen.getByText(/map/i)).toBeInTheDocument();
+ expect(screen.getByText(/settings/i)).toBeInTheDocument();
+ });
+
+ it("should have touch-optimized tap targets", () => {
+ const { container } = render();
+ const links = container.querySelectorAll("a");
+
+ links.forEach((link) => {
+ const classes = link.className;
+ expect(classes).toContain("h-12");
+ expect(classes).toContain("w-12");
+ });
+ });
+
+ it("should have smooth transition classes", () => {
+ const { container } = render();
+ const links = container.querySelectorAll("a");
+
+ links.forEach((link) => {
+ const classes = link.className;
+ expect(classes).toContain("transition-all");
+ expect(classes).toContain("duration-200");
+ });
+ });
+
+ it("should accept custom className", () => {
+ const { container } = render();
+ const nav = container.querySelector("nav");
+ expect(nav).toHaveClass("custom-class");
+ });
+
+ it("should be hidden on desktop (md:hidden)", () => {
+ const { container } = render();
+ const nav = container.querySelector("nav");
+ expect(nav).toHaveClass("md:hidden");
+ });
+
+ it("should have proper ARIA attributes", () => {
+ const { container } = render();
+ const nav = screen.getByRole("navigation");
+
+ expect(nav).toHaveAttribute("aria-label", "Mobile navigation");
+
+ const items = container.querySelectorAll("a");
+ expect(items.length).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/components/layout/MobileMenu.test.tsx b/tests/components/layout/MobileMenu.test.tsx
new file mode 100644
index 0000000..c99d57a
--- /dev/null
+++ b/tests/components/layout/MobileMenu.test.tsx
@@ -0,0 +1,121 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { MobileMenu } from "@/components/layout/MobileMenu";
+
+// Mock Next.js router
+vi.mock("next/navigation", () => ({
+ usePathname: vi.fn(() => "/"),
+}));
+
+describe("MobileMenu", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render mobile menu when open", () => {
+ const { container } = render();
+ const dialog = container.querySelector("[role='dialog']");
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ });
+
+ it("should not be visible when closed", () => {
+ const { container } = render();
+ const menu = container.querySelector("[role='dialog']");
+ expect(menu).toHaveClass("-translate-x-full");
+ });
+
+ it("should be visible when open", () => {
+ const { container } = render();
+ const menu = container.querySelector("[role='dialog']");
+ expect(menu).toHaveClass("translate-x-0");
+ });
+
+ it("should render menu items", () => {
+ render();
+ expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
+ expect(screen.getByText(/fleet/i)).toBeInTheDocument();
+ expect(screen.getByText(/map/i)).toBeInTheDocument();
+ expect(screen.getByText(/settings/i)).toBeInTheDocument();
+ });
+
+ it("should have touch-optimized menu items", () => {
+ const { container } = render();
+ const links = container.querySelectorAll("[role='dialog'] a");
+
+ links.forEach((link) => {
+ const classes = link.className;
+ expect(classes).toContain("min-h-12");
+ expect(classes).toContain("flex");
+ expect(classes).toContain("items-center");
+ });
+ });
+
+ it("should call onClose when close button is clicked", () => {
+ const onClose = vi.fn();
+ render();
+
+ const closeButton = screen.getByLabelText("Close mobile menu");
+ fireEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("should call onClose when a menu item is clicked", () => {
+ const onClose = vi.fn();
+ render();
+
+ const dashboardLink = screen.getByText(/dashboard/i);
+ fireEvent.click(dashboardLink);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("should close menu on Escape key press", async () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("should render close button with proper aria-label", () => {
+ render();
+ const closeButton = screen.getByLabelText("Close mobile menu");
+ expect(closeButton).toBeInTheDocument();
+ });
+
+ it("should have proper ARIA attributes", () => {
+ const { container } = render();
+ const dialog = container.querySelector("[role='dialog']");
+
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog).toHaveAttribute("aria-label", "Mobile navigation menu");
+ });
+
+ it("should accept custom className", () => {
+ const { container } = render(
+
+ );
+ const menu = container.querySelector("[role='dialog']");
+ expect(menu).toHaveClass("custom-class");
+ });
+
+ it("should have smooth transition animation", () => {
+ const { container } = render();
+ const menu = container.querySelector("[role='dialog']");
+ expect(menu).toHaveClass("transition-transform");
+ expect(menu).toHaveClass("duration-300");
+ });
+
+ it("should prevent body scroll when menu is open", () => {
+ const { rerender } = render();
+ // Initial state - overflow should be empty or unset
+ expect(document.body.style.overflow).not.toBe("hidden");
+
+ rerender();
+ expect(document.body.style.overflow).toBe("hidden");
+ });
+});
diff --git a/tests/components/layout/ResponsiveLayout.test.tsx b/tests/components/layout/ResponsiveLayout.test.tsx
new file mode 100644
index 0000000..bf77f91
--- /dev/null
+++ b/tests/components/layout/ResponsiveLayout.test.tsx
@@ -0,0 +1,167 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { ResponsiveLayout } from "@/components/layout/ResponsiveLayout";
+
+// Mock Next.js router
+vi.mock("next/navigation", () => ({
+ usePathname: vi.fn(() => "/"),
+}));
+
+// Mock swipe gesture hook
+vi.mock("@/hooks/useSwipeGesture", () => ({
+ useSwipeGesture: vi.fn(() => ({
+ current: document.createElement("div"),
+ })),
+}));
+
+describe("ResponsiveLayout", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render responsive layout", () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const main = container.querySelector("[role='main']");
+ expect(main).toBeInTheDocument();
+ });
+
+ it("should render header when provided", () => {
+ render(
+ Header Content }>
+