feat: add mobile nav drawer
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user