Skip to content
Open
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
4 changes: 2 additions & 2 deletions frontend/src/components/layout/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
19 changes: 9 additions & 10 deletions frontend/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,7 +13,7 @@ interface AppShellProps {
}

const AppShell: React.FC<AppShellProps> = ({ 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'
Expand All @@ -22,21 +23,19 @@ const AppShell: React.FC<AppShellProps> = ({ children }) => {
const drawerRef = useRef<HTMLDivElement>(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') {
Expand Down
46 changes: 43 additions & 3 deletions frontend/src/components/layout/MobileDrawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1100;
-webkit-tap-highlight-color: transparent;
}

.mobile-drawer {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
}
211 changes: 211 additions & 0 deletions frontend/src/components/layout/MobileDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('framer-motion')>('framer-motion')
return {
...actual,
motion: {
div: React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, ...props }, ref) => {
const { drag, dragConstraints, dragElastic, onDragEnd, initial, animate, exit, transition, ...rest } = props as Record<string, unknown>
return <div ref={ref} {...(rest as React.HTMLAttributes<HTMLDivElement>)}>{children}</div>
},
),
},
}
})

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(
<MemoryRouter>
<MobileDrawer
onClose={onClose}
currentPath={currentPath}
onNavigate={onNavigate}
/>
</MemoryRouter>,
),
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(
<MemoryRouter initialEntries={['/']}>
<MobileDrawer
onClose={closeFn}
currentPath="/"
onNavigate={(path) => {
mockPathname = path
closeFn()
}}
/>
</MemoryRouter>,
)

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<HTMLElement>(
'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<HTMLElement>(
'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<HTMLElement>(
'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')
})
})
})
Loading