diff --git a/e2e/mobile-audit.spec.ts b/e2e/mobile-audit.spec.ts index dcd6665..4c4f034 100644 --- a/e2e/mobile-audit.spec.ts +++ b/e2e/mobile-audit.spec.ts @@ -14,6 +14,10 @@ const pages = [ { name: 'Topic page', path: '/t/test-topic/abc123/' }, { name: 'Search page', path: '/search/' }, { name: 'Admin dashboard', path: '/admin/' }, + { name: 'Admin categories', path: '/admin/categories/' }, + { name: 'Admin moderation', path: '/admin/moderation/' }, + { name: 'Admin settings', path: '/admin/settings/' }, + { name: 'Admin users', path: '/admin/users/' }, { name: 'Settings page', path: '/settings/' }, { name: 'Profile page', path: '/u/jay/' }, { name: 'Accessibility statement', path: '/accessibility/' }, diff --git a/src/app/globals.css b/src/app/globals.css index e57cf39..50f9bb5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -281,6 +281,59 @@ body { color: var(--color-primary); } +/* Drawer animations for admin mobile sidebar */ +@keyframes slide-in-left { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slide-out-left { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@utility animate-slide-in-left { + animation: slide-in-left 200ms ease-out; +} + +@utility animate-slide-out-left { + animation: slide-out-left 200ms ease-in; +} + +@utility animate-fade-in { + animation: fade-in 200ms ease-out; +} + +@utility animate-fade-out { + animation: fade-out 200ms ease-in; +} + /* Component classes */ @layer components { .prose-barazo a { diff --git a/src/components/admin/admin-layout.test.tsx b/src/components/admin/admin-layout.test.tsx index 9c5f9d1..8e1a8e2 100644 --- a/src/components/admin/admin-layout.test.tsx +++ b/src/components/admin/admin-layout.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { AdminLayout } from './admin-layout' @@ -103,4 +104,95 @@ describe('AdminLayout', () => { const results = await axe(container) expect(results).toHaveNoViolations() }) + + describe('mobile sidebar drawer', () => { + it('renders a menu button for opening the mobile sidebar', () => { + render( + +

Content

+
+ ) + expect(screen.getByRole('button', { name: /open admin menu/i })).toBeInTheDocument() + }) + + it('opens the drawer when the menu button is clicked', async () => { + const user = userEvent.setup() + render( + +

Content

+
+ ) + + const menuButton = screen.getByRole('button', { name: /open admin menu/i }) + await user.click(menuButton) + + // The drawer should show an accessible dialog with the navigation + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() + }) + + it('closes the drawer when a nav link is clicked', async () => { + const user = userEvent.setup() + render( + +

Content

+
+ ) + + await user.click(screen.getByRole('button', { name: /open admin menu/i })) + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() + + // Click a nav link inside the drawer + const drawerNav = screen.getByRole('dialog', { name: /admin menu/i }) + const categoriesLink = drawerNav.querySelector('a[href="/admin/categories"]') + expect(categoriesLink).not.toBeNull() + await user.click(categoriesLink!) + + expect(screen.queryByRole('dialog', { name: /admin menu/i })).not.toBeInTheDocument() + }) + + it('closes the drawer when the close button is clicked', async () => { + const user = userEvent.setup() + render( + +

Content

+
+ ) + + await user.click(screen.getByRole('button', { name: /open admin menu/i })) + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close admin menu/i })) + expect(screen.queryByRole('dialog', { name: /admin menu/i })).not.toBeInTheDocument() + }) + + it('renders back to forum link in the mobile drawer', async () => { + const user = userEvent.setup() + render( + +

Content

+
+ ) + + await user.click(screen.getByRole('button', { name: /open admin menu/i })) + const drawer = screen.getByRole('dialog', { name: /admin menu/i }) + const backLink = drawer.querySelector('a[href="/"]') + expect(backLink).not.toBeNull() + }) + + it('passes axe accessibility check with drawer open', async () => { + const user = userEvent.setup() + const { container } = render( + +

Admin Page

+

Content

+
+ ) + + await user.click(screen.getByRole('button', { name: /open admin menu/i })) + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() + + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + }) }) diff --git a/src/components/admin/admin-layout.tsx b/src/components/admin/admin-layout.tsx index 8b8d7ec..14e8a80 100644 --- a/src/components/admin/admin-layout.tsx +++ b/src/components/admin/admin-layout.tsx @@ -1,13 +1,16 @@ /** * Admin layout with sidebar navigation. + * Desktop: persistent sidebar. Mobile (<768px): hamburger + slide-in drawer. * Used by all /admin/* pages. * @see specs/prd-web.md Section 4 (AdminLayout) */ 'use client' +import { useState, useCallback } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' +import * as Dialog from '@radix-ui/react-dialog' import { Article, ChartBar, @@ -22,6 +25,8 @@ import { ArrowLeft, ShieldWarning, SealCheck, + List, + X, } from '@phosphor-icons/react' import { cn } from '@/lib/utils' @@ -44,53 +49,110 @@ const NAV_ITEMS = [ { href: '/admin/plugins', label: 'Plugins', icon: PuzzlePiece }, ] +function AdminNav({ pathname, onLinkClick }: { pathname: string; onLinkClick?: () => void }) { + return ( + <> +
+ +
+ + + + ) +} + export function AdminLayout({ children }: AdminLayoutProps) { const pathname = usePathname() + const [drawerOpen, setDrawerOpen] = useState(false) + + const closeDrawer = useCallback(() => setDrawerOpen(false), []) return (
- {/* Sidebar */} -
+ + {/* Desktop sidebar */} + - {/* Main content */} -
{children}
+ {/* Main content - add top padding on mobile for the fixed bar */} +
{children}
) }