feat: add mobile nav drawer

This commit is contained in:
cita
2026-03-10 15:00:43 +08:00
parent 2d8e1b9019
commit d91a160455
2 changed files with 75 additions and 25 deletions
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App mobile sidebar', () => {
it('renders hamburger trigger for mobile navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain('aria-label="Open navigation"');
expect(source).toContain('MobileDrawer');
expect(source).toContain('mobile-nav');
});
});
+62 -25
View File
@@ -17,6 +17,7 @@ import { resolveLoginErrorMessage } from './loginError.js';
import { SITE_DOCS_URL } from './docsLink.js'; import { SITE_DOCS_URL } from './docsLink.js';
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
import { useIsMobile } from './components/useIsMobile.js'; import { useIsMobile } from './components/useIsMobile.js';
import { MobileDrawer } from './components/MobileDrawer.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js')); const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js')); const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js')); const Accounts = lazy(() => import('./pages/Accounts.js'));
@@ -378,6 +379,7 @@ function AppShell() {
const [showProfileModal, setShowProfileModal] = useState(false); const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false); const [showNotifications, setShowNotifications] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160); const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160); const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
@@ -551,6 +553,17 @@ function AppShell() {
return ( return (
<> <>
<header className="topbar"> <header className="topbar">
{isMobile && (
<button
className="topbar-icon-btn"
aria-label="Open navigation"
onClick={() => setDrawerOpen(true)}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="topbar-logo"> <div className="topbar-logo">
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} /> <img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span className="topbar-logo-text">Metapi</span> <span className="topbar-logo-text">Metapi</span>
@@ -673,32 +686,56 @@ function AppShell() {
</header> </header>
<div className="app-layout"> <div className="app-layout">
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}> {isMobile ? (
{sidebarGroups.map((group) => ( <MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<div key={group.label} className="sidebar-group"> <nav className="mobile-nav">
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>} {sidebarGroups.map((group) => (
{group.items.map((item) => ( <div key={group.label} className="mobile-nav-group">
<NavLink <div className="mobile-nav-label">{t(group.label)}</div>
key={item.to} {group.items.map((item) => (
to={item.to} <NavLink
end={item.to === '/' || item.to === '/settings'} key={item.to}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`} to={item.to}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined} end={item.to === '/' || item.to === '/settings'}
aria-label={sidebarCollapsed ? t(item.label) : undefined} className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
> onClick={() => setDrawerOpen(false)}
{item.icon} >
{!sidebarCollapsed && <span>{t(item.label)}</span>} {item.icon}
</NavLink> <span>{t(item.label)}</span>
</NavLink>
))}
</div>
))} ))}
</div> </nav>
))} </MobileDrawer>
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}> ) : (
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}> <aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" /> {sidebarGroups.map((group) => (
</svg> <div key={group.label} className="sidebar-group">
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>} {!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
</button> {group.items.map((item) => (
</aside> <NavLink
key={item.to}
to={item.to}
end={item.to === '/' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
aria-label={sidebarCollapsed ? t(item.label) : undefined}
>
{item.icon}
{!sidebarCollapsed && <span>{t(item.label)}</span>}
</NavLink>
))}
</div>
))}
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
</button>
</aside>
)}
<main className="main-content"> <main className="main-content">
<PageTransition> <PageTransition>