From f116be4f7ff401dc128e9bc57f31a4dd6d090332 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:18:37 +0400 Subject: [PATCH] =?UTF-8?q?fix(web-modules):=20I-3=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B5=D1=80=D1=8B=20#68=20M-2,=20#73=20M-2,=20#94=20T10.?= =?UTF-8?q?6/T10.7,=20proposal=20=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D1=85=20M-18/T9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/proposal.md | 5 +++-- web/src/App.test.tsx | 2 +- web/src/modules/jira/board/BoardPage.test.tsx | 10 +++++----- .../jira/board/components/BoardHeader.tsx | 2 +- web/src/modules/wiki/Tree.test.tsx | 8 ++++++++ web/src/modules/wiki/Tree.tsx | 19 ++++++++++++++----- web/src/shell/Sidebar.test.tsx | 13 +++++++++++++ web/src/shell/Sidebar.tsx | 19 +++++++++++++------ web/src/shell/shell.css | 1 + 9 files changed, 59 insertions(+), 20 deletions(-) diff --git a/openspec/changes/web-modules/proposal.md b/openspec/changes/web-modules/proposal.md index 5ae0d76..277f11b 100644 --- a/openspec/changes/web-modules/proposal.md +++ b/openspec/changes/web-modules/proposal.md @@ -37,8 +37,9 @@ по модулям и подсказками под полем; узкий экран с меткой проекта и сводной точкой состояния, модальная выезжающая панель; «Задачи» без горизонтальной прокрутки страницы, просрочка текстом; «Вики» открывает корень и показывает понятную шапку; мелкие тексты («Команды хаба», подсказка выбора проекта). -- **Закрываемые follow-ups:** #94 (веб: T4.1, T4.2, T4.4, T4.5, T10.1–T10.7, T9.1), #90 (T7.5, T7.6), #73 (M-1…M-7), - #68 (N-1, N-2, M-2, M-12, M-14, M-17, M-18, T5.3), #35 (M-9, O-16; T3.1/M-10 — отметить сделанным). +- **Закрываемые follow-ups:** #94 (веб: T4.1, T4.2, T4.4, T4.5, T10.1–T10.7), #90 (T7.5, T7.6), #73 (M-1…M-7), + #68 (N-1, N-2, M-2, M-12, M-14, M-17, T5.3), #35 (M-9, O-16; T3.1/M-10 — отметить сделанным). Остаются открытыми: #68 M-18 (заголовки разделов 16 против 24 px) и #94 T9.1 (лишний `GET /projects`) — + в коде не закрыты. ## Non-Goals diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 6f666c4..1de4031 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -43,7 +43,7 @@ describe("оболочка", () => { it("«Сменить токен» показывает экран входа", async () => { render(); - await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); + await userEvent.click(within(await screen.findByRole("complementary", { name: "Панель приложения" })).getByRole("button", { name: "Ещё" })); // #68 M-2: на доске есть ещё и «Ещё — доска» await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument(); diff --git a/web/src/modules/jira/board/BoardPage.test.tsx b/web/src/modules/jira/board/BoardPage.test.tsx index 7fdea35..7845828 100644 --- a/web/src/modules/jira/board/BoardPage.test.tsx +++ b/web/src/modules/jira/board/BoardPage.test.tsx @@ -103,7 +103,7 @@ describe("страница доски", () => { hub(); page(); await screen.findByText(/задач из 9/); - await userEvent.click(screen.getByRole("button", { name: "Ещё" })); + await userEvent.click(screen.getByRole("button", { name: "Ещё — доска" })); await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" })); const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value; expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" }); @@ -115,7 +115,7 @@ describe("страница доски", () => { localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}'); page(); await screen.findByText(/задач из 9/); - await userEvent.click(screen.getByRole("button", { name: "Ещё" })); + await userEvent.click(screen.getByRole("button", { name: "Ещё — доска" })); await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" })); await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" })); expect(localStorage.getItem("graph-pos:board")).toBeNull(); @@ -139,7 +139,7 @@ describe("страница доски", () => { await screen.findByText(/задач из 9/); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const click = vi.spyOn(input, "click"); - await userEvent.click(screen.getByRole("button", { name: "Ещё" })); + await userEvent.click(screen.getByRole("button", { name: "Ещё — доска" })); await userEvent.click(screen.getByRole("button", { name: "Загрузить файл…" })); expect(click).toHaveBeenCalled(); }); @@ -170,8 +170,8 @@ describe("страница доски", () => { page(); await screen.findByText(/задач из 9/); expect(screen.getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Ещё" })); - expect(screen.getByRole("button", { name: "Ещё" })).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(screen.getByRole("button", { name: "Ещё — доска" })); + expect(screen.getByRole("button", { name: "Ещё — доска" })).toHaveAttribute("aria-expanded", "true"); expect(screen.getByRole("button", { name: "Перечитать" })).toBeInTheDocument(); // меню действительно открыто expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument(); }); diff --git a/web/src/modules/jira/board/components/BoardHeader.tsx b/web/src/modules/jira/board/components/BoardHeader.tsx index 73d8a29..05cf239 100644 --- a/web/src/modules/jira/board/components/BoardHeader.tsx +++ b/web/src/modules/jira/board/components/BoardHeader.tsx @@ -35,7 +35,7 @@ export function BoardHeader(p: BoardHeaderProps) { title="Сроки блокеров, задачи без исполнителя, передачи между людьми"> Проблемы {p.problemCount} - + {(close) => ( <> { close(); p.onReload(); }}>Перечитать diff --git a/web/src/modules/wiki/Tree.test.tsx b/web/src/modules/wiki/Tree.test.tsx index 813fba9..390d876 100644 --- a/web/src/modules/wiki/Tree.test.tsx +++ b/web/src/modules/wiki/Tree.test.tsx @@ -76,4 +76,12 @@ describe("Tree", () => { expect(item("D")).toHaveAttribute("aria-posinset", "2"); expect(item("C")).toHaveAttribute("aria-setsize", "1"); }); + + it("T10.6: узел выбранной страницы пришёл раньше родителя — предки раскрываются, когда цепочка до корня стала полной", () => { + const { rerender } = render( {}} />); + expect(screen.queryByRole("treeitem", { name: "Стр C" })).toBeNull(); + rerender( {}} />); + expect(item("C")).toBeInTheDocument(); + expect(item("B")).toHaveAttribute("aria-expanded", "true"); + }); }); diff --git a/web/src/modules/wiki/Tree.tsx b/web/src/modules/wiki/Tree.tsx index f375db1..01eb38a 100644 --- a/web/src/modules/wiki/Tree.tsx +++ b/web/src/modules/wiki/Tree.tsx @@ -19,11 +19,20 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected: const [focus, setFocus] = useState(null); const refs = useRef(new Map()); - // предки выбранной страницы раскрываются при смене selected и когда её узел впервые появился в nodes (дерево из кэша - // дополнилось свежим набором). Новый массив nodes, добавление других узлов и ручное сворачивание родителя этого не - // повторяют: selectedKnown меняется только с false на true. Актуальные узлы читаются через byCodeRef; его эффект - // объявлен ВЫШЕ эффекта раскрытия, поэтому в одном проходе ref уже свежий - const selectedKnown = !!selected && byCode.has(selected); + // предки выбранной страницы раскрываются при смене selected и когда её цепочка предков до корня стала полной в nodes + // (дерево из кэша дополнилось свежим набором). Новый массив nodes с теми же узлами, добавление посторонних узлов и ручное + // сворачивание родителя раскрытия не повторяют: зависимость эффекта — булево selectedKnown, оно сменяется только при + // появлении или пропаже цепочки. Актуальные узлы читаются через byCodeRef; его эффект объявлен ВЫШЕ эффекта раскрытия, + // поэтому в одном проходе ref уже свежий + const selectedKnown = (() => { + if (!selected) return false; + let n = byCode.get(selected); + for (let guard = 0; n && guard <= nodes.length; guard++) { + if (!n.parent_code) return true; // дошли до корня: цепочка полная + n = byCode.get(n.parent_code); + } + return false; // узла или одного из предков в nodes ещё нет + })(); const byCodeRef = useRef(byCode); useEffect(() => { byCodeRef.current = byCode; }); useEffect(() => { diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index 649d977..d4e07ff 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -303,4 +303,17 @@ describe("боковая панель на узком экране", () => { await waitFor(() => expect(menu).toHaveFocus()); expect(screen.getByRole("main")).not.toHaveAttribute("inert"); }); + + it("#73 M-2: панель — модальное окно «Меню» (role=dialog, aria-modal), прокрутка страницы под ней заблокирована до закрытия", async () => { + hub(); + open("/app/jira/board?project=AWPCA"); + await userEvent.click(await screen.findByRole("button", { name: "Меню" })); + const dlg = screen.getByRole("dialog", { name: "Меню" }); + expect(dlg).toHaveAttribute("aria-modal", "true"); + expect(within(dlg).getByRole("complementary", { name: "Панель приложения" })).toBeInTheDocument(); + expect(document.documentElement.style.overflow).toBe("hidden"); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "Меню" })).toBeNull(); + expect(document.documentElement.style.overflow).toBe(""); + }); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 562ca66..3e70d57 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -62,10 +62,11 @@ function NarrowShell(p: Props) { - {open && <> -
- - } + {open && ( +
+
+ +
)} ); } @@ -85,9 +86,15 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); }; document.addEventListener("keydown", esc); // выезжающая панель модальна: страница и строка приложения недоступны с клавиатуры и для чтения с экрана - const behind = [...document.querySelectorAll(".shell > :not(.sidebar):not(.sidebar-backdrop)")]; + const behind = [...document.querySelectorAll(".shell > :not(.sidebar-modal)")]; behind.forEach((el) => el.setAttribute("inert", "")); - return () => { document.removeEventListener("keydown", esc); behind.forEach((el) => el.removeAttribute("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) => (