Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(admin): add responsive mobile sidebar drawer (#155)

* feat(admin): add responsive mobile sidebar drawer

The admin layout had a fixed 256px sidebar with no responsive breakpoints,
leaving only ~119px for content on mobile viewports. Now the sidebar is
hidden below md (768px) and replaced with a hamburger button that opens
a Radix Dialog-based slide-in drawer.

- Extract shared AdminNav component for desktop sidebar and mobile drawer
- Add hamburger menu button (md:hidden) with sticky top bar
- Slide-in drawer with overlay, close button, and auto-close on link click
- Proper a11y: dialog role, sr-only title, focus management via Radix
- Add slide/fade animation keyframes to globals.css
- Add admin sub-routes to mobile-audit e2e test coverage
- 6 new unit tests for drawer open/close/a11y behavior

* style(admin): fix Prettier formatting in admin layout

authored by

Guido X Jansen and committed by
GitHub
bd47ba70 6e93b14f

+249 -38
+4
e2e/mobile-audit.spec.ts
··· 14 14 { name: 'Topic page', path: '/t/test-topic/abc123/' }, 15 15 { name: 'Search page', path: '/search/' }, 16 16 { name: 'Admin dashboard', path: '/admin/' }, 17 + { name: 'Admin categories', path: '/admin/categories/' }, 18 + { name: 'Admin moderation', path: '/admin/moderation/' }, 19 + { name: 'Admin settings', path: '/admin/settings/' }, 20 + { name: 'Admin users', path: '/admin/users/' }, 17 21 { name: 'Settings page', path: '/settings/' }, 18 22 { name: 'Profile page', path: '/u/jay/' }, 19 23 { name: 'Accessibility statement', path: '/accessibility/' },
+53
src/app/globals.css
··· 281 281 color: var(--color-primary); 282 282 } 283 283 284 + /* Drawer animations for admin mobile sidebar */ 285 + @keyframes slide-in-left { 286 + from { 287 + transform: translateX(-100%); 288 + } 289 + to { 290 + transform: translateX(0); 291 + } 292 + } 293 + 294 + @keyframes slide-out-left { 295 + from { 296 + transform: translateX(0); 297 + } 298 + to { 299 + transform: translateX(-100%); 300 + } 301 + } 302 + 303 + @keyframes fade-in { 304 + from { 305 + opacity: 0; 306 + } 307 + to { 308 + opacity: 1; 309 + } 310 + } 311 + 312 + @keyframes fade-out { 313 + from { 314 + opacity: 1; 315 + } 316 + to { 317 + opacity: 0; 318 + } 319 + } 320 + 321 + @utility animate-slide-in-left { 322 + animation: slide-in-left 200ms ease-out; 323 + } 324 + 325 + @utility animate-slide-out-left { 326 + animation: slide-out-left 200ms ease-in; 327 + } 328 + 329 + @utility animate-fade-in { 330 + animation: fade-in 200ms ease-out; 331 + } 332 + 333 + @utility animate-fade-out { 334 + animation: fade-out 200ms ease-in; 335 + } 336 + 284 337 /* Component classes */ 285 338 @layer components { 286 339 .prose-barazo a {
+92
src/components/admin/admin-layout.test.tsx
··· 4 4 5 5 import { describe, it, expect, vi } from 'vitest' 6 6 import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 7 8 import { axe } from 'vitest-axe' 8 9 import { AdminLayout } from './admin-layout' 9 10 ··· 102 103 ) 103 104 const results = await axe(container) 104 105 expect(results).toHaveNoViolations() 106 + }) 107 + 108 + describe('mobile sidebar drawer', () => { 109 + it('renders a menu button for opening the mobile sidebar', () => { 110 + render( 111 + <AdminLayout> 112 + <p>Content</p> 113 + </AdminLayout> 114 + ) 115 + expect(screen.getByRole('button', { name: /open admin menu/i })).toBeInTheDocument() 116 + }) 117 + 118 + it('opens the drawer when the menu button is clicked', async () => { 119 + const user = userEvent.setup() 120 + render( 121 + <AdminLayout> 122 + <p>Content</p> 123 + </AdminLayout> 124 + ) 125 + 126 + const menuButton = screen.getByRole('button', { name: /open admin menu/i }) 127 + await user.click(menuButton) 128 + 129 + // The drawer should show an accessible dialog with the navigation 130 + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() 131 + }) 132 + 133 + it('closes the drawer when a nav link is clicked', async () => { 134 + const user = userEvent.setup() 135 + render( 136 + <AdminLayout> 137 + <p>Content</p> 138 + </AdminLayout> 139 + ) 140 + 141 + await user.click(screen.getByRole('button', { name: /open admin menu/i })) 142 + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() 143 + 144 + // Click a nav link inside the drawer 145 + const drawerNav = screen.getByRole('dialog', { name: /admin menu/i }) 146 + const categoriesLink = drawerNav.querySelector('a[href="/admin/categories"]') 147 + expect(categoriesLink).not.toBeNull() 148 + await user.click(categoriesLink!) 149 + 150 + expect(screen.queryByRole('dialog', { name: /admin menu/i })).not.toBeInTheDocument() 151 + }) 152 + 153 + it('closes the drawer when the close button is clicked', async () => { 154 + const user = userEvent.setup() 155 + render( 156 + <AdminLayout> 157 + <p>Content</p> 158 + </AdminLayout> 159 + ) 160 + 161 + await user.click(screen.getByRole('button', { name: /open admin menu/i })) 162 + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() 163 + 164 + await user.click(screen.getByRole('button', { name: /close admin menu/i })) 165 + expect(screen.queryByRole('dialog', { name: /admin menu/i })).not.toBeInTheDocument() 166 + }) 167 + 168 + it('renders back to forum link in the mobile drawer', async () => { 169 + const user = userEvent.setup() 170 + render( 171 + <AdminLayout> 172 + <p>Content</p> 173 + </AdminLayout> 174 + ) 175 + 176 + await user.click(screen.getByRole('button', { name: /open admin menu/i })) 177 + const drawer = screen.getByRole('dialog', { name: /admin menu/i }) 178 + const backLink = drawer.querySelector('a[href="/"]') 179 + expect(backLink).not.toBeNull() 180 + }) 181 + 182 + it('passes axe accessibility check with drawer open', async () => { 183 + const user = userEvent.setup() 184 + const { container } = render( 185 + <AdminLayout> 186 + <h1>Admin Page</h1> 187 + <p>Content</p> 188 + </AdminLayout> 189 + ) 190 + 191 + await user.click(screen.getByRole('button', { name: /open admin menu/i })) 192 + expect(screen.getByRole('dialog', { name: /admin menu/i })).toBeInTheDocument() 193 + 194 + const results = await axe(container) 195 + expect(results).toHaveNoViolations() 196 + }) 105 197 }) 106 198 })
+100 -38
src/components/admin/admin-layout.tsx
··· 1 1 /** 2 2 * Admin layout with sidebar navigation. 3 + * Desktop: persistent sidebar. Mobile (<768px): hamburger + slide-in drawer. 3 4 * Used by all /admin/* pages. 4 5 * @see specs/prd-web.md Section 4 (AdminLayout) 5 6 */ 6 7 7 8 'use client' 8 9 10 + import { useState, useCallback } from 'react' 9 11 import Link from 'next/link' 10 12 import { usePathname } from 'next/navigation' 13 + import * as Dialog from '@radix-ui/react-dialog' 11 14 import { 12 15 Article, 13 16 ChartBar, ··· 22 25 ArrowLeft, 23 26 ShieldWarning, 24 27 SealCheck, 28 + List, 29 + X, 25 30 } from '@phosphor-icons/react' 26 31 import { cn } from '@/lib/utils' 27 32 ··· 44 49 { href: '/admin/plugins', label: 'Plugins', icon: PuzzlePiece }, 45 50 ] 46 51 52 + function AdminNav({ pathname, onLinkClick }: { pathname: string; onLinkClick?: () => void }) { 53 + return ( 54 + <> 55 + <div className="flex h-14 items-center border-b border-border px-4"> 56 + <Link 57 + href="/" 58 + className="flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" 59 + aria-label="Back to forum" 60 + onClick={onLinkClick} 61 + > 62 + <ArrowLeft size={16} aria-hidden="true" /> 63 + Back to forum 64 + </Link> 65 + </div> 66 + 67 + <nav aria-label="Admin navigation" className="flex-1 px-3 py-4"> 68 + <ul className="space-y-1"> 69 + {NAV_ITEMS.map((item) => { 70 + const isActive = pathname === item.href 71 + const Icon = item.icon 72 + return ( 73 + <li key={item.href}> 74 + <Link 75 + href={item.href} 76 + aria-current={isActive ? 'page' : undefined} 77 + onClick={onLinkClick} 78 + className={cn( 79 + 'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors', 80 + isActive 81 + ? 'bg-primary/10 font-medium text-primary' 82 + : 'text-muted-foreground hover:bg-muted hover:text-foreground' 83 + )} 84 + > 85 + <Icon size={18} aria-hidden="true" /> 86 + {item.label} 87 + </Link> 88 + </li> 89 + ) 90 + })} 91 + </ul> 92 + </nav> 93 + </> 94 + ) 95 + } 96 + 47 97 export function AdminLayout({ children }: AdminLayoutProps) { 48 98 const pathname = usePathname() 99 + const [drawerOpen, setDrawerOpen] = useState(false) 100 + 101 + const closeDrawer = useCallback(() => setDrawerOpen(false), []) 49 102 50 103 return ( 51 104 <div className="flex min-h-screen bg-background"> 52 - {/* Sidebar */} 53 - <aside className="flex w-64 shrink-0 flex-col border-r border-border bg-card"> 54 - <div className="flex h-14 items-center border-b border-border px-4"> 55 - <Link 56 - href="/" 57 - className="flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" 58 - aria-label="Back to forum" 59 - > 60 - <ArrowLeft size={16} aria-hidden="true" /> 61 - Back to forum 62 - </Link> 63 - </div> 105 + {/* Mobile top bar */} 106 + <div className="fixed inset-x-0 top-0 z-30 flex h-14 items-center border-b border-border bg-card px-4 md:hidden"> 107 + <Dialog.Root open={drawerOpen} onOpenChange={setDrawerOpen}> 108 + <Dialog.Trigger asChild> 109 + <button 110 + type="button" 111 + aria-label="Open admin menu" 112 + className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 113 + > 114 + <List size={20} aria-hidden="true" /> 115 + </button> 116 + </Dialog.Trigger> 64 117 65 - <nav aria-label="Admin navigation" className="flex-1 px-3 py-4"> 66 - <ul className="space-y-1"> 67 - {NAV_ITEMS.map((item) => { 68 - const isActive = pathname === item.href 69 - const Icon = item.icon 70 - return ( 71 - <li key={item.href}> 72 - <Link 73 - href={item.href} 74 - aria-current={isActive ? 'page' : undefined} 75 - className={cn( 76 - 'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors', 77 - isActive 78 - ? 'bg-primary/10 font-medium text-primary' 79 - : 'text-muted-foreground hover:bg-muted hover:text-foreground' 80 - )} 118 + <Dialog.Portal> 119 + <Dialog.Overlay className="fixed inset-0 z-40 bg-black/50 data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in" /> 120 + <Dialog.Content 121 + aria-label="Admin menu" 122 + aria-describedby={undefined} 123 + className="fixed inset-y-0 left-0 z-50 flex w-64 flex-col bg-card shadow-lg data-[state=closed]:animate-slide-out-left data-[state=open]:animate-slide-in-left" 124 + > 125 + <Dialog.Title className="sr-only">Admin menu</Dialog.Title> 126 + <div className="flex h-14 items-center justify-between border-b border-border px-4"> 127 + <span aria-hidden="true" className="text-sm font-medium text-foreground"> 128 + Admin 129 + </span> 130 + <Dialog.Close asChild> 131 + <button 132 + type="button" 133 + aria-label="Close admin menu" 134 + className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 81 135 > 82 - <Icon size={18} aria-hidden="true" /> 83 - {item.label} 84 - </Link> 85 - </li> 86 - ) 87 - })} 88 - </ul> 89 - </nav> 136 + <X size={18} aria-hidden="true" /> 137 + </button> 138 + </Dialog.Close> 139 + </div> 140 + 141 + <AdminNav pathname={pathname} onLinkClick={closeDrawer} /> 142 + </Dialog.Content> 143 + </Dialog.Portal> 144 + </Dialog.Root> 145 + 146 + <span className="ml-3 text-sm font-medium text-foreground">Admin</span> 147 + </div> 148 + 149 + {/* Desktop sidebar */} 150 + <aside className="hidden w-64 shrink-0 flex-col border-r border-border bg-card md:flex"> 151 + <AdminNav pathname={pathname} /> 90 152 </aside> 91 153 92 - {/* Main content */} 93 - <main className="min-w-0 flex-1 p-6">{children}</main> 154 + {/* Main content - add top padding on mobile for the fixed bar */} 155 + <main className="min-w-0 flex-1 p-6 pt-20 md:pt-6">{children}</main> 94 156 </div> 95 157 ) 96 158 }