feat(ui): F8-09 — turn report sticky icon-popup section menu (#52)
- 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:
@@ -3,12 +3,16 @@
|
||||
// the orchestrator's sections render, then drives the page through
|
||||
// the targeted-test contract:
|
||||
//
|
||||
// 1. Every TOC anchor click scrolls the matching section into view
|
||||
// and the section is present in the DOM with at least one row
|
||||
// (or its empty-state copy when it is intentionally empty).
|
||||
// 2. The "back to map" button switches to the map view.
|
||||
// 3. The mobile <select> fallback scrolls a section into view on
|
||||
// a narrow viewport.
|
||||
// 1. Every TOC menuitem click scrolls the matching section into
|
||||
// view and the section is present in the DOM with at least one
|
||||
// row (or its empty-state copy when it is intentionally empty).
|
||||
// 2. On a narrow viewport the same trigger surfaces a bottom-sheet
|
||||
// popover and the same menuitem flow lands the chosen section.
|
||||
//
|
||||
// F8-09 collapsed the desktop sidebar and mobile `<select>` into a
|
||||
// single sticky icon-popup trigger; the in-report "Back to map"
|
||||
// button was removed (the affordance lives in the app-shell view
|
||||
// menu, exercised by `game-shell.spec.ts`).
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
@@ -36,9 +40,9 @@ const BATTLE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
// SECTIONS lists every TOC slug paired with a row-presence hook.
|
||||
// `expectRow` is null for sections that the seeded report
|
||||
// intentionally leaves empty so the empty-state copy is asserted
|
||||
// instead. The orchestrator's section order must match this
|
||||
// list — the spec relies on each slug having a `report-toc-<slug>`
|
||||
// and a `report-section-<slug>` testid.
|
||||
// instead. The orchestrator's section order must match this list —
|
||||
// the spec relies on each slug having a `report-toc-item-<slug>` in
|
||||
// the popover and a `report-section-<slug>` testid in the body.
|
||||
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
||||
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
||||
{ slug: "votes", expectRow: "votes-mine" },
|
||||
@@ -226,7 +230,7 @@ async function bootSession(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe("Phase 23 report view", () => {
|
||||
test("every TOC anchor lands its section in view", async ({
|
||||
test("every popover menuitem lands its section in view", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -252,9 +256,16 @@ test.describe("Phase 23 report view", () => {
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
|
||||
const trigger = page.getByTestId("report-toc-trigger");
|
||||
const surface = page.getByTestId("report-toc-surface");
|
||||
|
||||
for (const entry of SECTIONS) {
|
||||
const anchor = page.getByTestId(`report-toc-${entry.slug}`);
|
||||
await anchor.click();
|
||||
await trigger.click();
|
||||
await expect(surface).toBeVisible();
|
||||
await page.getByTestId(`report-toc-item-${entry.slug}`).click();
|
||||
// The popover closes on selection and the section
|
||||
// scrolls into view.
|
||||
await expect(surface).toHaveCount(0);
|
||||
const section = page.getByTestId(`report-section-${entry.slug}`);
|
||||
await expect(section).toBeInViewport();
|
||||
if (entry.expectRow !== null) {
|
||||
@@ -280,30 +291,12 @@ test.describe("Phase 23 report view", () => {
|
||||
// scroll-restoration the test asserted exist any more. Re-adding that
|
||||
// behaviour would be a production change outside this test migration.
|
||||
|
||||
test("back-to-map button switches to the map view", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"navigation is identical on mobile",
|
||||
);
|
||||
// F8-09 removed the in-report "Back to map" button — the same
|
||||
// affordance lives in the app-shell view menu, exercised by
|
||||
// `game-shell.spec.ts` ("header view-menu navigates to every
|
||||
// active view").
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||
await page.evaluate(
|
||||
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
||||
GAME_ID,
|
||||
);
|
||||
|
||||
await page.getByTestId("report-back-to-map").click();
|
||||
// The single-URL app-shell keeps the address bar at the app base;
|
||||
// the active map view is the navigation signal.
|
||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile select scrolls to the chosen section", async ({
|
||||
test("mobile bottom-sheet popover lands the chosen section", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -320,12 +313,25 @@ test.describe("Phase 23 report view", () => {
|
||||
GAME_ID,
|
||||
);
|
||||
|
||||
const mobileSelect = page.getByTestId("report-toc-mobile");
|
||||
await expect(mobileSelect).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
await mobileSelect.selectOption("bombings");
|
||||
|
||||
const trigger = page.getByTestId("report-toc-trigger");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
|
||||
const surface = page.getByTestId("report-toc-surface");
|
||||
await expect(surface).toBeVisible();
|
||||
// Below 768 px the surface re-styles into a fixed
|
||||
// bottom-sheet anchored above the bottom-tabs bar.
|
||||
const position = await surface.evaluate(
|
||||
(el) => getComputedStyle(el).position,
|
||||
);
|
||||
expect(position).toBe("fixed");
|
||||
|
||||
await page.getByTestId("report-toc-item-bombings").click();
|
||||
await expect(surface).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("report-section-bombings"),
|
||||
).toBeInViewport();
|
||||
|
||||
@@ -63,17 +63,19 @@ describe("active-view stubs", () => {
|
||||
expect(node).toHaveTextContent("ship groups");
|
||||
});
|
||||
|
||||
test("report view mounts with the TOC and the back-to-map link", () => {
|
||||
test("report view mounts with the icon-popup TOC", () => {
|
||||
// Phase 23 replaces the Phase 10 stub with the full report
|
||||
// orchestrator. The orchestrator mounts the table of contents
|
||||
// regardless of report state; the inner sections render
|
||||
// loading copy until a `RenderedReportSource` lands via
|
||||
// context. This test only smokes the orchestrator scaffold —
|
||||
// per-section assertions live in `report-section-*.test.ts`.
|
||||
// F8-09 collapsed the TOC into a single sticky icon-popup
|
||||
// trigger; "Back to map" lives in the app-shell view menu.
|
||||
const r = render(ReportView);
|
||||
expect(r.getByTestId("active-view-report")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-toc")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-back-to-map")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-toc-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("mail stub renders its localised title", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user