141 lines
7.8 KiB
TypeScript
141 lines
7.8 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||
import type { NavItem } from "../modules/types.ts";
|
||
import { Link } from "react-router";
|
||
import type { Ref } from "react";
|
||
import { clearToken } from "../auth/token.ts";
|
||
import { useActivitySummary } from "./activity/activity.ts";
|
||
import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx";
|
||
import { GROUPS, OVERVIEW } from "./nav.tsx";
|
||
import { withProject } from "../projects/current.ts";
|
||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||
import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
||
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
|
||
|
||
export type Section = string;
|
||
|
||
// узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4)
|
||
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;
|
||
|
||
type Props = { section: Section | null; project: string | null };
|
||
|
||
// боковая панель (#69, design 1–4); section = null на путях вне разделов — активной ссылки нет
|
||
export function Sidebar(p: Props) {
|
||
const narrow = useSyncExternalStore(subscribe, isNarrow);
|
||
return narrow ? <NarrowShell {...p} /> : <Panel {...p} />;
|
||
}
|
||
|
||
const SUMMARY_TEXT = { error: "ошибка", busy: "идёт обновление", online: "агент на связи", offline: "агент офлайн" } as const;
|
||
|
||
// сводная точка состояния модулей: цвет не единственный признак — состояние в aria-label и title
|
||
function SummaryDot() {
|
||
const summary = useActivitySummary();
|
||
const text = `Состояние модулей: ${SUMMARY_TEXT[summary]}`;
|
||
return <span role="img" aria-label={text} title={text} className={`topbar__dot topbar__dot--${summary}`} />;
|
||
}
|
||
|
||
function NarrowShell(p: Props) {
|
||
const [open, setOpen] = useState(false);
|
||
const menuBtn = useRef<HTMLButtonElement>(null);
|
||
// объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке
|
||
const drawer = useMemo(() => ({ close: () => setOpen(false), navigated: () => setOpen(false) }), []);
|
||
// фокус возвращается на «Меню» после закрытия: пока панель открыта, строка инертна и фокус на неё не встал бы
|
||
const wasOpen = useRef(false);
|
||
useEffect(() => {
|
||
if (wasOpen.current && !open) menuBtn.current?.focus();
|
||
wasOpen.current = open;
|
||
}, [open]);
|
||
return (
|
||
<>
|
||
<header className="topbar" aria-label="Строка приложения">
|
||
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
|
||
onClick={() => setOpen(true)}><IconMenu /></button>
|
||
{p.project && <span className="topbar__project" title={`Проект ${p.project}`}>{p.project}</span>}
|
||
<span className="sidebar__spacer" />
|
||
<SummaryDot />
|
||
</header>
|
||
<ModuleEventStrip />
|
||
{open && (
|
||
<div className="sidebar-modal" role="dialog" aria-modal="true" aria-label="Меню">
|
||
<div className="sidebar-backdrop" data-testid="sidebar-backdrop" onClick={drawer.close} />
|
||
<Panel {...p} drawer={drawer} />
|
||
</div>)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, «Обзор» и разделы по модулям, внизу состояние
|
||
// модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73)
|
||
function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) {
|
||
const qc = useQueryClient();
|
||
const [stored, setCollapsed] = useState(readSidebar);
|
||
const collapsed = !drawer && stored;
|
||
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
|
||
const href = (to: string) => (project ? to + withProject("", project) : to);
|
||
const first = useRef<HTMLAnchorElement>(null);
|
||
useEffect(() => {
|
||
if (!drawer) return;
|
||
first.current?.focus();
|
||
const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); };
|
||
document.addEventListener("keydown", esc);
|
||
// выезжающая панель модальна: страница и строка приложения недоступны с клавиатуры и для чтения с экрана
|
||
const behind = [...document.querySelectorAll(".shell > :not(.sidebar-modal)")];
|
||
behind.forEach((el) => el.setAttribute("inert", ""));
|
||
// и страница под подложкой не прокручивается (#73 M-2)
|
||
const root = document.documentElement, overflow = root.style.overflow;
|
||
root.style.overflow = "hidden";
|
||
return () => {
|
||
document.removeEventListener("keydown", esc); behind.forEach((el) => el.removeAttribute("inert"));
|
||
root.style.overflow = overflow;
|
||
};
|
||
}, [drawer]);
|
||
const link = (s: NavItem, ref?: Ref<HTMLAnchorElement>) => (
|
||
<Link key={s.to} ref={ref} to={href(s.to)} className="sidebar__link"
|
||
aria-current={section === s.to ? "page" : undefined} title={collapsed ? s.label : undefined} onClick={drawer?.navigated}>
|
||
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
|
||
</Link>
|
||
);
|
||
return (
|
||
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
|
||
<div className="sidebar__head">
|
||
{!collapsed && <span className="sidebar__brand">SDLC Hub</span>}
|
||
{drawer
|
||
? <IconButton label="Закрыть меню" icon={<IconClose />} onClick={drawer.close} />
|
||
: <IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
|
||
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />}
|
||
</div>
|
||
{collapsed
|
||
? project && (
|
||
<button type="button" className="sidebar__project-tag" aria-label={`Проект ${project} — развернуть панель`}
|
||
title={`Проект ${project}`} onClick={() => fold(false)}>{project.slice(0, 6)}</button>)
|
||
: <ProjectPicker />}
|
||
<div className="sidebar__scroll">
|
||
<nav className="sidebar__nav" aria-label="Разделы">
|
||
{link(OVERVIEW, first)}
|
||
{GROUPS.map((g) => (
|
||
<div key={g.name} role="group" aria-labelledby={`sidebar-group-${g.name}`} className="sidebar__group">
|
||
<span id={`sidebar-group-${g.name}`} className={"sidebar__group-title" + (collapsed ? " visually-hidden" : "")}>{g.title}</span>
|
||
{g.nav.map((n) => link(n))}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
<span className="sidebar__spacer" />
|
||
<ModuleStatusBlock project={project} variant={collapsed ? "collapsed" : "expanded"} />
|
||
</div>
|
||
<div className="sidebar__foot">
|
||
<ThemeSwitch />
|
||
<Menu label="Ещё" up align={collapsed ? "start" : "end"}>
|
||
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
|
||
</Menu>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|