105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { Button, Drawer, Layout } from "tdesign-react";
|
|
import { X } from "lucide-react";
|
|
import { DEFAULT_ROUTE_ID, type RouteId } from "../router";
|
|
import AppContent from "./Content";
|
|
import AppHeader from "./Header";
|
|
import AppMenu from "./Menu";
|
|
import { useDrawerFocus } from "../hooks/useDrawerFocus";
|
|
|
|
export interface AppLayoutProps {
|
|
activeId?: RouteId;
|
|
adminName?: string;
|
|
adminUsername?: string;
|
|
children: ReactNode;
|
|
onNavigate: (id: RouteId) => void;
|
|
onLogout?: () => void;
|
|
onOpenPassword?: () => void;
|
|
}
|
|
|
|
export default function AppLayout({ activeId = DEFAULT_ROUTE_ID, adminName, adminUsername, children, onNavigate, onLogout, onOpenPassword }: AppLayoutProps) {
|
|
const [desktopVisible, setDesktopVisible] = useState(true);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [mobileVisible, setMobileVisible] = useState(false);
|
|
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
|
useDrawerFocus(mobileVisible, ".tn-mobile-drawer");
|
|
|
|
useEffect(() => {
|
|
const media = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 900px)") : null;
|
|
const sync = () => {
|
|
const mobile = media?.matches ?? window.innerWidth <= 900;
|
|
setIsMobile(mobile);
|
|
if (!mobile) setMobileVisible(false);
|
|
};
|
|
sync();
|
|
if (!media) return;
|
|
if (typeof media.addEventListener === "function") {
|
|
media.addEventListener("change", sync);
|
|
return () => media.removeEventListener("change", sync);
|
|
}
|
|
media.addListener?.(sync);
|
|
return () => media.removeListener?.(sync);
|
|
}, []);
|
|
|
|
const restoreMenuFocus = () => {
|
|
window.requestAnimationFrame(() => menuButtonRef.current?.focus());
|
|
};
|
|
|
|
const closeMobileMenu = () => {
|
|
setMobileVisible(false);
|
|
restoreMenuFocus();
|
|
};
|
|
|
|
const choose = (id: RouteId) => {
|
|
onNavigate(id);
|
|
closeMobileMenu();
|
|
};
|
|
|
|
const toggleMenu = () => {
|
|
if (isMobile) {
|
|
setMobileVisible(value => !value);
|
|
return;
|
|
}
|
|
setMobileVisible(false);
|
|
setDesktopVisible(value => !value);
|
|
};
|
|
|
|
return (
|
|
<Layout className="tn-shell tn-layout">
|
|
<nav id="tn-desktop-navigation" className={`tn-desktop-menu ${desktopVisible ? "" : "is-hidden"}`} aria-label="主导航" aria-hidden={!desktopVisible || undefined} inert={!desktopVisible || undefined}>
|
|
<AppMenu activeId={activeId} onChange={choose} />
|
|
</nav>
|
|
<Layout className="tn-layout-main">
|
|
<AppHeader
|
|
adminName={adminName}
|
|
adminUsername={adminUsername}
|
|
menuButtonRef={menuButtonRef}
|
|
menuOpen={isMobile ? mobileVisible : desktopVisible}
|
|
menuControls={isMobile ? "tn-mobile-navigation" : "tn-desktop-navigation"}
|
|
onToggleMenu={toggleMenu}
|
|
onLogout={onLogout}
|
|
onOpenPassword={onOpenPassword}
|
|
/>
|
|
<AppContent>{children}</AppContent>
|
|
</Layout>
|
|
<Drawer
|
|
className="tn-mobile-drawer"
|
|
visible={mobileVisible}
|
|
placement="left"
|
|
size="216px"
|
|
closeBtn={false}
|
|
header={<div className="tn-mobile-drawer-header"><span>主导航</span><Button variant="text" shape="square" aria-label="关闭导航菜单" onClick={closeMobileMenu} icon={<X size={17} />} /></div>}
|
|
footer={false}
|
|
destroyOnClose
|
|
onClose={closeMobileMenu}
|
|
>
|
|
<nav id="tn-mobile-navigation" className="tn-mobile-menu-shell" aria-label="主导航">
|
|
<AppMenu activeId={activeId} onChange={choose} width={["216px", "64px"]} />
|
|
</nav>
|
|
</Drawer>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
export { AppContent, AppHeader, AppMenu };
|