Skip to content
File

Blob: src/client/components/root-shell.tsx

typescript72 lines
1import { useCallback, useEffect, useMemo, useState } from "react";
2import { Outlet, useRouter } from "@tanstack/react-router";
3import { ConfirmContainer } from "./confirm";
4import { ToastContainer } from "./toast";
5import { ShortcutHelp } from "./ui/shortcut-help";
6import { MobileDrawerContext, type MobileDrawerState } from "../hooks/use-mobile-drawer";
7import { useSessionRehydration } from "@/client/hooks/use-session-rehydration";
8 
9export function RootShell() {
10 const router = useRouter();
11 const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
12 const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
13 const [routeAnnouncement, setRouteAnnouncement] = useState("");
14 
15 useSessionRehydration();
16 
17 const toggleDrawer = useCallback(() => setMobileDrawerOpen((open) => !open), []);
18 const closeDrawer = useCallback(() => {
19 setMobileDrawerOpen(false);
20 }, []);
21 
22 // Shortcut help: `?` key toggles modal
23 useEffect(() => {
24 function handleShortcutHelp(e: KeyboardEvent) {
25 const tag = (e.target as HTMLElement)?.tagName;
26 const editable = (e.target as HTMLElement)?.isContentEditable;
27 if (tag === "INPUT" || tag === "TEXTAREA" || editable) return;
28 if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
29 e.preventDefault();
30 setShortcutHelpOpen((o) => !o);
31 }
32 }
33 document.addEventListener("keydown", handleShortcutHelp);
34 return () => document.removeEventListener("keydown", handleShortcutHelp);
35 }, []);
36 
37 // Route change: close drawer immediately; focus and announce after route
38 // resolution so the main content and document title are current.
39 useEffect(() => {
40 const unsubscribeBeforeNavigate = router.subscribe("onBeforeNavigate", (event) => {
41 if (event.pathChanged) setMobileDrawerOpen(false);
42 });
43 const unsubscribeResolved = router.subscribe("onResolved", (event) => {
44 if (!event.pathChanged) return;
45 const main = document.getElementById("main-content");
46 if (main) main.focus({ preventScroll: true });
47 setRouteAnnouncement(document.title);
48 });
49 return () => {
50 unsubscribeBeforeNavigate();
51 unsubscribeResolved();
52 };
53 }, [router]);
54 
55 const drawerState = useMemo<MobileDrawerState>(
56 () => ({ open: mobileDrawerOpen, toggle: toggleDrawer, close: closeDrawer }),
57 [mobileDrawerOpen, toggleDrawer, closeDrawer],
58 );
59 
60 return (
61 <MobileDrawerContext.Provider value={drawerState}>
62 <Outlet />
63 <ToastContainer />
64 <ConfirmContainer />
65 <ShortcutHelp open={shortcutHelpOpen} onClose={() => setShortcutHelpOpen(false)} />
66 <div aria-live="polite" aria-atomic="true" className="sr-only">
67 {routeAnnouncement}
68 </div>
69 </MobileDrawerContext.Provider>
70 );
71}