From c9672183cf7ff2b97129419cc20e058786021d58 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 08:53:06 +0400 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D0=BA=D0=BE=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=BE=D0=BC=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?:=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D1=81=20=C2=AB?= =?UTF-8?q?=D0=9C=D0=B5=D0=BD=D1=8E=C2=BB=20=D0=B8=20=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=D0=B7=D0=B6=D0=B0=D1=8E=D1=89=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/board/board.css | 2 +- web/src/shell/Sidebar.test.tsx | 54 ++++++++++++++++++++++++- web/src/shell/Sidebar.tsx | 74 ++++++++++++++++++++++++++++------ web/src/shell/shell.css | 12 ++++++ 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/web/src/board/board.css b/web/src/board/board.css index 253c5f4..fdd7ca1 100644 --- a/web/src/board/board.css +++ b/web/src/board/board.css @@ -139,7 +139,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .who.none::before{background:transparent;border:1px solid var(--color-text-secondary)} /* 7. Панель проблем справа от доски, липнет под шапкой; прокручивается содержимое внутри рамки Panel */ -.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)} +.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--topbar-h,0px) + var(--head-h,0px) + 8px);max-height:calc(100vh - var(--topbar-h,0px) - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)} .drawer>.ui-panel{display:flex;flex-direction:column;gap:var(--space-3);min-height:0;min-width:0;flex:1 1 auto;padding:var(--space-4) 0 var(--space-4) var(--space-4)} .drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2);padding-right:var(--space-4)} .drawer-body{overflow-y:auto;min-height:0;padding-right:var(--space-4)} diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index bc9cab9..a213fef 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createMemoryRouter } from "react-router"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App.tsx"; import { getAuth, setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; @@ -177,3 +177,55 @@ describe("боковая панель", () => { expect(bar()).not.toHaveClass("is-collapsed"); }); }); + +describe("боковая панель на узком экране", () => { + beforeEach(() => { + setToken("tok"); localStorage.clear(); + vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("max-width: 639px"), media: q, addEventListener() {}, removeEventListener() {} })); + }); + afterEach(() => { vi.unstubAllGlobals(); }); + + it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => { + hub(); + open("/app/board?project=AWPCA"); + const top = await screen.findByRole("banner", { name: "Строка приложения" }); + expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); + expect(within(top).getByText("Хаб Jira")).toBeInTheDocument(); + expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument(); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + }); + + it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => { + hub(); + open("/app/board?project=AWPCA"); + const menu = await screen.findByRole("button", { name: "Меню" }); + await userEvent.click(menu); + expect(menu).toHaveAttribute("aria-expanded", "true"); + const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); + expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus(); + expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull(); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + expect(menu).toHaveFocus(); + }); + + it("выбор раздела закрывает панель и открывает раздел", async () => { + hub(); + const router = open("/app/board?project=AWPCA"); + await userEvent.click(await screen.findByRole("button", { name: "Меню" })); + await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" })); + expect(router.state.location.pathname).toBe("/app/issues"); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + }); + + it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => { + hub(); + open("/app/board?project=AWPCA"); + await userEvent.click(await screen.findByRole("button", { name: "Меню" })); + await userEvent.click(screen.getByTestId("sidebar-backdrop")); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Меню" })); + await userEvent.click(within(bar()).getByRole("button", { name: "Закрыть меню" })); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + }); +}); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 27d7930..ec405e6 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -1,10 +1,10 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Link } from "react-router"; import { clearToken } from "../auth/token.ts"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; -import { IconBoard, IconButton, IconList, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; +import { IconBoard, IconButton, IconClose, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; import { DataStatus } from "./DataStatus.tsx"; import { readSidebar, saveSidebar } from "./sidebarPref.ts"; @@ -15,19 +15,69 @@ const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [ { to: "/issues", label: "Задачи", icon: }, ]; -// боковая панель (#69, design 1–3); section = null на путях вне разделов — активной ссылки нет: -// название и свёртка, проект, разделы, внизу состояние данных, тема, меню «Ещё» -export function Sidebar({ section, project }: { section: Section | null; project: string | null }) { +// узкий экран (<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 ? : ; +} + +function NarrowShell(p: Props) { + const [open, setOpen] = useState(false); + const menuBtn = useRef(null); + // объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке + const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []); + return ( + <> +
+ + Хаб Jira + + +
+ {open && <> +
+ + } + + ); +} + +// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, разделы, внизу состояние данных, тема, меню «Ещё» +function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) { const qc = useQueryClient(); - const [collapsed, setCollapsed] = useState(readSidebar); + const [stored, setCollapsed] = useState(readSidebar); + const collapsed = !drawer && stored; const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; const href = (to: Section) => (project ? to + withProject("", project) : to); + const first = useRef(null); + useEffect(() => { + if (!drawer) return; + first.current?.focus(); + const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); }; + document.addEventListener("keydown", esc); + return () => document.removeEventListener("keydown", esc); + }, [drawer]); return ( -