feat(ui): F8-09 — turn report sticky icon-popup section menu (#52)
Tests · UI / test (push) Successful in 2m45s
Tests · UI / test (pull_request) Successful in 2m52s

- Replace the 14 rem sticky sidebar (and its mobile <select> twin)
  with a single sticky icon-popup trigger pinned to the top-right
  corner of the report column. Trigger shows `≡` followed by the
  currently active section title (CSS-clamped with text-overflow:
  ellipsis so long RU titles cannot bloat the button). Click opens
  an anchored popover on desktop and a fixed bottom-sheet on
  <768.98 px (mirrors lib/active-view/map-toggles.svelte).
- Each menuitem closes the popover and scrolls the matching
  `<section id="report-<slug>">` into view. The scroll is deferred
  one animation frame so the surface unmount + restoreFocus's
  focus restoration on the (sticky) trigger commit first; otherwise
  the focus call could cancel the just-started smooth/instant
  scroll under desktop Chromium and WebKit.
- Drop the in-report "Back to map" button — the same affordance
  lives in the app-shell view menu (tests/e2e/game-shell.spec.ts
  covers it).
- Tighten the report grid to a single flex column so the section
  body now occupies the full container width.
- i18n: remove game.report.back_to_map and
  game.report.toc.mobile_label; add game.report.toc.open and
  game.report.toc.close (mirrors game.map.toggles.open/close).
- Tests: Vitest report-toc.test.ts rewritten for the new icon-popup
  contract; Playwright report-sections.spec.ts switches the anchor
  loop to trigger → menuitem and adds a mobile bottom-sheet
  assertion; game-shell-stubs.test.ts no longer asserts the
  back-to-map button on the report orchestrator.
- Docs: ui/docs/report-view.md (TOC + i18n + test seams) and
  docs/FUNCTIONAL{,_ru}.md §6.4 updated. The stale SvelteKit
  Snapshot reference (the route file was removed by the single-URL
  app-shell) is dropped at the same time.

Refs: #52 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 18:11:00 +02:00
parent 147c7d0a6a
commit cfbe052242
10 changed files with 383 additions and 321 deletions
+99 -88
View File
@@ -1,9 +1,10 @@
// Vitest coverage for the Phase 23 Report View's table of contents.
// Smokes the anchor list, the active-link state, the back-to-map
// navigation, and the mobile <select> fallback. The
// IntersectionObserver-driven active-section computation lives in
// the orchestrator (`report.svelte`); this test only checks the
// presentational pieces of the TOC.
// Vitest coverage for the F8-09 turn-report icon-popup section menu.
// Smokes the sticky trigger, its active-title label, the open/close
// state machine, popover items + active highlight, scroll-on-pick,
// and the Escape + outside-click dismissal paths. The
// IntersectionObserver-driven `activeSlug` is computed by the
// orchestrator (`report.svelte`) and passed in via prop; the TOC
// itself owns no observers.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -12,15 +13,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
// The TOC's "back to map" button switches the active in-game view via
// `activeView.select("map")` (the single-URL app-shell has no
// `/games/:id/map` route). Mock the nav store so the spy captures the
// view switch and no real `pushState` runs.
const activeViewSelectMock = vi.hoisted(() => vi.fn());
vi.mock("$lib/app-nav.svelte", () => ({
activeView: { select: activeViewSelectMock },
}));
import ReportToc, {
type TocEntry,
} from "../src/lib/active-view/report/report-toc.svelte";
@@ -31,124 +23,143 @@ const ENTRIES: readonly TocEntry[] = [
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
];
function stubReducedMotion(reduce: boolean): void {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: reduce && query.includes("reduce"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
beforeEach(() => {
i18n.resetForTests("en");
activeViewSelectMock.mockClear();
});
describe("report TOC", () => {
test("renders one anchor per entry and one option in the mobile select", () => {
describe("report TOC (icon-popup)", () => {
test("renders the trigger with the active section title; popover closed by default", () => {
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
for (const e of ENTRIES) {
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
}
const mobile = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
expect(mobile.options).toHaveLength(ENTRIES.length);
expect(mobile.value).toBe("galaxy-summary");
const trigger = ui.getByTestId("report-toc-trigger");
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(trigger).toHaveTextContent("galaxy summary");
expect(ui.queryByTestId("report-toc-surface")).toBeNull();
});
test("marks the active anchor with aria-current=location and a class", () => {
test("clicking the trigger opens the popover with one menuitem per entry", async () => {
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
await fireEvent.click(ui.getByTestId("report-toc-trigger"));
expect(ui.getByTestId("report-toc-trigger")).toHaveAttribute(
"aria-expanded",
"true",
);
const surface = ui.getByTestId("report-toc-surface");
expect(surface).toHaveAttribute("role", "menu");
for (const entry of ENTRIES) {
const item = ui.getByTestId(`report-toc-item-${entry.slug}`);
expect(item).toHaveAttribute("role", "menuitem");
}
});
test("marks the active item with aria-current=location and .active", async () => {
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "bombings" },
});
const active = ui.getByTestId("report-toc-bombings");
await fireEvent.click(ui.getByTestId("report-toc-trigger"));
const active = ui.getByTestId("report-toc-item-bombings");
expect(active).toHaveAttribute("aria-current", "location");
expect(active).toHaveClass("active");
const inactive = ui.getByTestId("report-toc-votes");
const inactive = ui.getByTestId("report-toc-item-votes");
expect(inactive).not.toHaveAttribute("aria-current");
expect(inactive).not.toHaveClass("active");
});
test("back-to-map button switches the active view to the map", async () => {
test("trigger label tracks activeSlug across re-renders", async () => {
const ui = render(ReportToc, {
props: {
entries: ENTRIES,
activeSlug: "galaxy-summary",
},
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
const button = ui.getByTestId("report-back-to-map");
await fireEvent.click(button);
expect(activeViewSelectMock).toHaveBeenCalledWith("map");
expect(ui.getByTestId("report-toc-trigger")).toHaveTextContent(
"galaxy summary",
);
await ui.rerender({ entries: ENTRIES, activeSlug: "votes" });
expect(ui.getByTestId("report-toc-trigger")).toHaveTextContent("votes");
});
test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => {
// Stub `scrollIntoView` on the target — jsdom does not
// implement it. The TOC also reads
// `prefers-reduced-motion`; the matchMedia stub forces a
// stable `behavior: "auto"` so the assertion is reproducible.
test("clicking a menuitem scrolls the target into view and closes the popover", async () => {
// jsdom does not implement `scrollIntoView`; stub it on the
// section the spec is about to pick. `matchMedia` is forced to
// `reduce` so the assertion stays stable.
const scrollSpy = vi.fn();
const target = document.createElement("section");
target.id = "report-bombings";
target.scrollIntoView = scrollSpy;
document.body.appendChild(target);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: query.includes("reduce"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
stubReducedMotion(true);
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
await fireEvent.click(ui.getByTestId("report-toc-bombings"));
await fireEvent.click(ui.getByTestId("report-toc-trigger"));
await fireEvent.click(ui.getByTestId("report-toc-item-bombings"));
// The component defers `scrollIntoView` one frame so the
// surface unmount + focus restoration run first. Flush the
// frame before asserting.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
expect(scrollSpy).toHaveBeenCalledWith({
behavior: "auto",
block: "start",
});
expect(ui.queryByTestId("report-toc-surface")).toBeNull();
expect(ui.getByTestId("report-toc-trigger")).toHaveAttribute(
"aria-expanded",
"false",
);
target.remove();
});
test("mobile select scrolls to the chosen section without navigating", async () => {
const scrollSpy = vi.fn();
const target = document.createElement("section");
target.id = "report-votes";
target.scrollIntoView = scrollSpy;
document.body.appendChild(target);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: () => ({
matches: false,
media: "(prefers-reduced-motion: no-preference)",
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
test("Escape closes the popover", async () => {
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
await fireEvent.click(ui.getByTestId("report-toc-trigger"));
expect(ui.getByTestId("report-toc-surface")).toBeInTheDocument();
await fireEvent.keyDown(document, { key: "Escape" });
expect(ui.queryByTestId("report-toc-surface")).toBeNull();
});
test("outside click closes the popover", async () => {
const outside = document.createElement("button");
document.body.appendChild(outside);
const ui = render(ReportToc, {
props: {
entries: ENTRIES,
activeSlug: "galaxy-summary",
},
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
await fireEvent.change(select, { target: { value: "votes" } });
expect(scrollSpy).toHaveBeenCalled();
expect(activeViewSelectMock).not.toHaveBeenCalled();
target.remove();
await fireEvent.click(ui.getByTestId("report-toc-trigger"));
expect(ui.getByTestId("report-toc-surface")).toBeInTheDocument();
await fireEvent.click(outside);
expect(ui.queryByTestId("report-toc-surface")).toBeNull();
outside.remove();
});
// Tests intentionally validate the *type* of the entries prop is
// exposed correctly so future widening of the list does not
// silently drop entries. TypeScript already enforces this through
// `TocEntry`; the assertion below is a soft check so a stray
// `as unknown as ...` cast surfaces fast.
// Soft type-level check: a stray `as unknown as ...` cast on
// `TocEntry` would silently widen the prop shape — assert the
// runtime values look as expected.
test("TocEntry exposes a slug and a TranslationKey", () => {
const slug: string = ENTRIES[0]!.slug;
const key: TranslationKey = ENTRIES[0]!.titleKey;