import { Fragment, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, ThemeSwitch } from "../ui/index.ts"; import "./shell.css"; // Оболочка AURA: закреплённая боковая панель (240 px, свёртка до 64 px) и страница справа; прокрутка — у окна. // Узкий экран (<640 px): панели в потоке нет — закреплённая строка 56 px с кнопкой «Меню», панель выезжает поверх. export type NavItem = { href: string; label: string; icon: ReactNode }; // ссылка по умолчанию — ; с роутером: link={({ href, ...p }) => } export type ShellLink = (p: { href: string; className: string; "aria-current"?: "page"; title?: string; onClick?: () => void; children: ReactNode; }) => ReactNode; const plainLink: ShellLink = ({ href, ...rest }) => ; type Props = { brand: string; // название приложения, Bold 16/24 nav: NavItem[]; current: string | null; // href активного раздела; null — активного нет link?: ShellLink; top?: (collapsed: boolean) => ReactNode; // под названием: выбор проекта/аккаунта и т. п. bottom?: (collapsed: boolean) => ReactNode; // над подвалом: состояние данных, кнопка синхронизации topbarEnd?: ReactNode; // правый край строки на телефоне (компактное состояние) menu?: (close: () => void) => ReactNode; // пункты меню «Ещё» в подвале (MenuItem …) storageKey?: string; // ключ свёртки панели в localStorage wide?: boolean; // страница во всю ширину (доски, графики), иначе до 1440 px children: ReactNode; }; const NARROW = "(max-width: 639px)"; const subscribe = (cb: () => void) => { if (typeof matchMedia !== "function") return () => {}; const m = matchMedia(NARROW); m.addEventListener?.("change", cb); return () => m.removeEventListener?.("change", cb); }; const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches; export function AppShell({ wide, children, ...p }: Props) { const narrow = useSyncExternalStore(subscribe, isNarrow); return (
{narrow ? : }
{children}
); } type PanelProps = Omit; function NarrowBar(p: PanelProps) { const [open, setOpen] = useState(false); const menuBtn = useRef(null); // объект стабилен, пока панель открыта: эффект фокуса и Escape не перезапускается на каждой отрисовке const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []); return ( <>
{p.brand} {p.topbarEnd}
{open && <>
} ); } function readFold(key: string): boolean { try { return localStorage.getItem(key) === "collapsed"; } catch { return false; } } function saveFold(key: string, collapsed: boolean): void { try { if (collapsed) localStorage.setItem(key, "collapsed"); else localStorage.removeItem(key); } catch { /* только в памяти */ } } function Sidebar({ brand, nav, current, link = plainLink, top, bottom, menu, storageKey = "app-sidebar", drawer }: PanelProps & { drawer?: { close(): void; navigated(): void } }) { const [stored, setStored] = useState(() => readFold(storageKey)); const collapsed = !drawer && stored; const fold = (c: boolean) => { setStored(c); saveFold(storageKey, c); }; const aside = useRef(null); useEffect(() => { if (!drawer) return; aside.current?.querySelector(".sidebar__link")?.focus(); const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); }; document.addEventListener("keydown", esc); return () => document.removeEventListener("keydown", esc); }, [drawer]); return ( ); }