diff --git a/frontend/src/components/layout/AppShell.test.tsx b/frontend/src/components/layout/AppShell.test.tsx index 3d8d605e..f14c7587 100644 --- a/frontend/src/components/layout/AppShell.test.tsx +++ b/frontend/src/components/layout/AppShell.test.tsx @@ -170,12 +170,12 @@ describe('Mobile drawer keyboard', () => { await act(async () => { fireEvent.click(hamburger) }) // Drawer should be open - expect(screen.getByRole('navigation', { name: /mobile navigation/i })).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: /mobile navigation/i })).toBeInTheDocument() await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }) }) - expect(screen.queryByRole('navigation', { name: /mobile navigation/i })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: /mobile navigation/i })).not.toBeInTheDocument() }) }) diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 9fc3d2fe..4e14c694 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { AnimatePresence } from 'framer-motion' +import { useMediaQuery } from '../../hooks/useMediaQuery' import Sidebar from './Sidebar' import TopNav from './TopNav' import MobileDrawer from './MobileDrawer' @@ -12,7 +13,7 @@ interface AppShellProps { } const AppShell: React.FC = ({ children }) => { - const [isMobile, setIsMobile] = useState(window.innerWidth < 768) + const isMobile = useMediaQuery('(max-width: 767px)') const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState( localStorage.getItem('sidebar_collapsed') === 'true' @@ -22,21 +23,19 @@ const AppShell: React.FC = ({ children }) => { const drawerRef = useRef(null) useEffect(() => { - const handleResize = () => { - setIsMobile(window.innerWidth < 768) - if (window.innerWidth >= 768) { - setIsDrawerOpen(false) - } + if (isMobile === false) { + setIsDrawerOpen(false) } - - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) + }, [isMobile]) useEffect(() => { localStorage.setItem('sidebar_collapsed', sidebarCollapsed.toString()) }, [sidebarCollapsed]) + useEffect(() => { + setIsDrawerOpen(false) + }, [location.pathname]) + useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { diff --git a/frontend/src/components/layout/MobileDrawer.css b/frontend/src/components/layout/MobileDrawer.css index cbd2d5bb..5b6512b7 100644 --- a/frontend/src/components/layout/MobileDrawer.css +++ b/frontend/src/components/layout/MobileDrawer.css @@ -6,6 +6,7 @@ bottom: 0; background: rgba(0, 0, 0, 0.5); z-index: 1100; + -webkit-tap-highlight-color: transparent; } .mobile-drawer { @@ -18,15 +19,40 @@ border-top-right-radius: 20px; box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2); z-index: 1200; - max-height: 70vh; + max-height: 85vh; overflow: hidden; + touch-action: none; + will-change: transform; + -webkit-overflow-scrolling: touch; +} + +/* Short screens: 70vh */ +@media (max-height: 600px) { + .mobile-drawer { + max-height: 70vh; + } +} + +/* Tall screens: 85vh (default above) */ +@media (min-height: 900px) { + .mobile-drawer { + max-height: 85vh; + } +} + +.drawer-drag-handle { + width: 36px; + height: 4px; + background: var(--border-color, #cbd5e1); + border-radius: 2px; + margin: 8px auto 0; } .drawer-header { display: flex; align-items: center; justify-content: space-between; - padding: 20px 24px 16px; + padding: 12px 24px 16px; border-bottom: 1px solid var(--border-color, #e2e8f0); } @@ -61,6 +87,7 @@ padding: 8px 12px 24px; padding-bottom: calc(24px + env(safe-area-inset-bottom)); overflow-y: auto; + overscroll-behavior: contain; } .drawer-nav ul { @@ -83,7 +110,7 @@ border-radius: 10px; text-align: left; cursor: pointer; - transition: all 0.2s; + transition: background-color 0.15s ease, color 0.15s ease; color: var(--text-secondary, #64748b); text-decoration: none; font-size: 16px; @@ -96,6 +123,10 @@ color: var(--text-primary, #1a202c); } +.drawer-nav .nav-item:active { + background: var(--bg-tertiary, #e2e8f0); +} + .drawer-nav .nav-item.active { background: var(--primary-light, rgba(59, 130, 246, 0.1)); color: var(--primary, #3b82f6); @@ -120,3 +151,12 @@ .drawer-nav .nav-label { white-space: nowrap; } + +/* Focus trap: prevent focus from escaping the drawer */ +.trap-focus { + outline: none; +} + +.trap-focus:focus { + outline: none; +} diff --git a/frontend/src/components/layout/MobileDrawer.test.tsx b/frontend/src/components/layout/MobileDrawer.test.tsx new file mode 100644 index 00000000..8913e3cf --- /dev/null +++ b/frontend/src/components/layout/MobileDrawer.test.tsx @@ -0,0 +1,211 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, test, expect, beforeEach, vi } from 'vitest' +import MobileDrawer, { NAV_ITEMS } from './MobileDrawer' + +vi.mock('framer-motion', async () => { + const actual = await vi.importActual('framer-motion') + return { + ...actual, + motion: { + div: React.forwardRef>( + ({ children, ...props }, ref) => { + const { drag, dragConstraints, dragElastic, onDragEnd, initial, animate, exit, transition, ...rest } = props as Record + return
)}>{children}
+ }, + ), + }, + } +}) + +const mockNavigate = vi.fn() +let mockPathname = '/' + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + useLocation: () => ({ pathname: mockPathname, search: '', hash: '', state: null, key: 'default' }), + } +}) + +const renderDrawer = (overrides: { currentPath?: string; onClose?: () => void; onNavigate?: (path: string) => void } = {}) => { + const onClose = overrides.onClose ?? vi.fn() + const onNavigate = overrides.onNavigate ?? vi.fn() + const currentPath = overrides.currentPath ?? '/' + + return { + ...render( + + + , + ), + onClose, + onNavigate, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/' +}) + +// ─── Rendering ───────────────────────────────────────────────────────────── + +describe('MobileDrawer rendering', () => { + test('renders navigation dialog with all nav items', () => { + renderDrawer() + expect(screen.getByRole('dialog', { name: /mobile navigation/i })).toBeInTheDocument() + expect(screen.getByText('Navigation')).toBeInTheDocument() + NAV_ITEMS.forEach((item) => { + expect(screen.getByText(item.label)).toBeInTheDocument() + }) + }) + + test('has aria-modal attribute', () => { + renderDrawer() + const dialog = screen.getByRole('dialog', { name: /mobile navigation/i }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + test('sets aria-current="page" on active nav item', () => { + renderDrawer({ currentPath: '/agents' }) + const btn = screen.getByRole('button', { name: /agents/i }) + expect(btn).toHaveAttribute('aria-current', 'page') + }) + + test('does not set aria-current on inactive nav items', () => { + renderDrawer({ currentPath: '/agents' }) + const btn = screen.getByRole('button', { name: /wallet/i }) + expect(btn).not.toHaveAttribute('aria-current') + }) + + test('renders close button with accessible label', () => { + renderDrawer() + expect(screen.getByRole('button', { name: /close navigation menu/i })).toBeInTheDocument() + }) + + test('renders drag handle', () => { + renderDrawer() + const dialog = screen.getByRole('dialog', { name: /mobile navigation/i }) + const handle = dialog.querySelector('.drawer-drag-handle') + expect(handle).toBeInTheDocument() + }) +}) + +// ─── Close callbacks ─────────────────────────────────────────────────────── + +describe('MobileDrawer close behavior', () => { + test('calls onClose when close button is clicked', () => { + const { onClose } = renderDrawer() + fireEvent.click(screen.getByRole('button', { name: /close navigation menu/i })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test('calls onClose when backdrop is clicked', () => { + const { onClose } = renderDrawer() + const backdrop = document.querySelector('.drawer-backdrop')! + fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test('calls onNavigate and onClose flow via nav item click', () => { + const { onNavigate } = renderDrawer() + fireEvent.click(screen.getByRole('button', { name: /dashboard/i })) + expect(onNavigate).toHaveBeenCalledWith('/') + }) +}) + +// ─── Close-on-navigate (drawer resets on page navigation) ────────────────── + +describe('MobileDrawer close-on-navigate', () => { + test('AppShell closes drawer on pathname change (integration)', () => { + const closeFn = vi.fn() + render( + + { + mockPathname = path + closeFn() + }} + /> + , + ) + + fireEvent.click(screen.getByRole('button', { name: /agents/i })) + expect(closeFn).toHaveBeenCalled() + }) +}) + +// ─── Focus trap ──────────────────────────────────────────────────────────── + +describe('MobileDrawer focus trap', () => { + test('traps focus within the drawer on Tab', () => { + renderDrawer() + const dialog = screen.getByRole('dialog', { name: /mobile navigation/i }) + const focusableEls = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + + expect(focusableEls.length).toBeGreaterThan(0) + + const lastEl = focusableEls[focusableEls.length - 1] + lastEl.focus() + expect(document.activeElement).toBe(lastEl) + + act(() => { + fireEvent.keyDown(lastEl, { key: 'Tab' }) + }) + expect(document.activeElement).toBe(focusableEls[0]) + }) + + test('traps focus in reverse with Shift+Tab', () => { + renderDrawer() + const dialog = screen.getByRole('dialog', { name: /mobile navigation/i }) + const focusableEls = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + + const firstEl = focusableEls[0] + firstEl.focus() + expect(document.activeElement).toBe(firstEl) + + act(() => { + fireEvent.keyDown(firstEl, { key: 'Tab', shiftKey: true }) + }) + expect(document.activeElement).toBe(focusableEls[focusableEls.length - 1]) + }) + + test('focuses first focusable element on mount', () => { + renderDrawer() + const dialog = screen.getByRole('dialog', { name: /mobile navigation/i }) + const focusableEls = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + expect(document.activeElement).toBe(focusableEls[0]) + }) +}) + +// ─── NAV_ITEMS export ────────────────────────────────────────────────────── + +describe('NAV_ITEMS', () => { + test('exports 5 navigation items', () => { + expect(NAV_ITEMS).toHaveLength(5) + }) + + test('each item has path, icon, and label', () => { + NAV_ITEMS.forEach((item) => { + expect(item).toHaveProperty('path') + expect(item).toHaveProperty('icon') + expect(item).toHaveProperty('label') + }) + }) +}) diff --git a/frontend/src/components/layout/MobileDrawer.tsx b/frontend/src/components/layout/MobileDrawer.tsx index a16f0ba8..c3ca2f51 100644 --- a/frontend/src/components/layout/MobileDrawer.tsx +++ b/frontend/src/components/layout/MobileDrawer.tsx @@ -1,91 +1,157 @@ -import React, { forwardRef } from 'react' +import React, { forwardRef, useEffect, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { motion } from 'framer-motion' +import { motion, PanInfo } from 'framer-motion' import { LayoutDashboard, PlusCircle, Bot, Wallet, History } from 'lucide-react' import './MobileDrawer.css' +export const NAV_ITEMS = [ + { path: '/', icon: LayoutDashboard, label: 'Dashboard' }, + { path: '/tasks/new', icon: PlusCircle, label: 'New Task' }, + { path: '/tasks/history', icon: History, label: 'Task History' }, + { path: '/agents', icon: Bot, label: 'Agents' }, + { path: '/wallet', icon: Wallet, label: 'Wallet' }, +] as const + interface MobileDrawerProps { onClose: () => void currentPath: string onNavigate: (path: string) => void } -const MobileDrawer = forwardRef(({ - onClose, - currentPath, - onNavigate -}, ref) => { - const { t } = useTranslation() - - const navItems = [ - { path: '/', icon: , label: t('nav.dashboard') }, - { path: '/tasks/new', icon: , label: t('nav.newTask') }, - { path: '/tasks/history', icon: , label: t('nav.taskHistory') }, - { path: '/agents', icon: , label: t('nav.agents') }, - { path: '/wallet', icon: , label: t('nav.wallet') }, - ] - - const handleKeyDown = (e: React.KeyboardEvent, path: string) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + +const MobileDrawer = forwardRef( + ({ onClose, currentPath, onNavigate }, ref) => { + const { t } = useTranslation() + const drawerRef = useRef(null) + const previousFocusRef = useRef(null) + const combinedRef = useCallback( + (node: HTMLDivElement | null) => { + drawerRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as React.MutableRefObject).current = node + }, + [ref], + ) + + useEffect(() => { + previousFocusRef.current = document.activeElement as HTMLElement + return () => { + previousFocusRef.current?.focus() + } + }, []) + + useEffect(() => { + const drawer = drawerRef.current + if (!drawer) return + + const focusableEls = drawer.querySelectorAll(FOCUSABLE_SELECTOR) + if (focusableEls.length === 0) return + + focusableEls[0]?.focus() + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + + const els = Array.from(drawer.querySelectorAll(FOCUSABLE_SELECTOR)) + if (els.length === 0) return + + const first = els[0] + const last = els[els.length - 1] + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + + const handleDragEnd = (_: unknown, info: PanInfo) => { + if (info.offset.x > 100 || info.velocity.x > 500) { + onClose() + } + } + + const handleNavigate = (path: string) => { onNavigate(path) } - } - - return ( - <> - - -
-

{t('nav.navigation')}

- -
- - -
- - ) -}) + + return ( + <> +