sdlc-hub/web/src/shell/Sidebar.tsx
2026-10-24 15:38:46 +04:00

141 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}