fix(web-modules): I-3 маркеры #68 M-2, #73 M-2, #94 T10.6/T10.7, proposal без незакрытых M-18/T9.1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 15:18:37 +04:00
parent 425d09eb45
commit f116be4f7f
9 changed files with 59 additions and 20 deletions

View File

@ -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

View File

@ -43,7 +43,7 @@ describe("оболочка", () => {
it("«Сменить токен» показывает экран входа", async () => {
render(<App router={at("/app/jira/board")} />);
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();

View File

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

View File

@ -35,7 +35,7 @@ export function BoardHeader(p: BoardHeaderProps) {
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
Проблемы <Badge tone={p.problemCount ? "error" : "neutral"}>{p.problemCount}</Badge>
</Button>
<Menu label="Ещё">
<Menu label="Ещё — доска">
{(close) => (
<>
<MenuItem title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onSelect={() => { close(); p.onReload(); }}>Перечитать</MenuItem>

View File

@ -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(<Tree nodes={[N("A", 0, null), N("C", 2, "B")]} selected="C" onOpen={() => {}} />);
expect(screen.queryByRole("treeitem", { name: "Стр C" })).toBeNull();
rerender(<Tree nodes={NODES} selected="C" onOpen={() => {}} />);
expect(item("C")).toBeInTheDocument();
expect(item("B")).toHaveAttribute("aria-expanded", "true");
});
});

View File

@ -19,11 +19,20 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected:
const [focus, setFocus] = useState<string | null>(null);
const refs = useRef(new Map<string, HTMLDivElement>());
// предки выбранной страницы раскрываются при смене 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(() => {

View File

@ -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("");
});
});

View File

@ -62,10 +62,11 @@ function NarrowShell(p: Props) {
<SummaryDot />
</header>
<ModuleEventStrip />
{open && <>
<div className="sidebar-backdrop" data-testid="sidebar-backdrop" onClick={drawer.close} />
<Panel {...p} drawer={drawer} />
</>}
{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>)}
</>
);
}
@ -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<HTMLAnchorElement>) => (
<Link key={s.to} ref={ref} to={href(s.to)} className="sidebar__link"

View File

@ -82,3 +82,4 @@
padding:var(--space-2) var(--space-4);border-bottom:1px solid var(--color-border-default);
background:var(--color-bg-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;overflow-wrap:anywhere;
}
.sidebar-modal{display:contents} /* обёртка модального окна «Меню»: раскладку не меняет */