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
+9 -4
View File
@@ -694,10 +694,15 @@ The web client renders the report as one section per FBS array
foreign ship classes, battles, bombings, approaching groups, my /
foreign / uninhabited / unknown planets, ships in production,
cargo routes, my fleets, my / foreign / unidentified ship groups).
Empty sections render explicit empty-state copy. Section anchors
are exposed in a sticky table of contents (a `<select>` on mobile)
and the scroll position is preserved across active-view switches
via SvelteKit's `Snapshot` API.
Empty sections render explicit empty-state copy. Section
navigation is exposed through a sticky icon-popup menu pinned to
the top-right of the report column (an anchored popover on desktop
and a fixed bottom-sheet on mobile); the trigger label tracks the
section currently in view, and picking a menuitem scrolls the
matching section into view. Re-entering the report active view
remounts the component and resets the scroll position; the active
highlight is re-derived from the IntersectionObserver as the user
scrolls.
The Bombings section is a flat read-only table — one row per
bombing event, columns for `attacker`, `attack_power`, `wiped`
+7 -3
View File
@@ -713,9 +713,13 @@ Web-клиент рендерит отчёт как одну секцию на
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
производстве, грузовые маршруты, мои флоты, мои / чужие /
неопознанные группы кораблей). Пустые секции получают явную копию
empty-state. Якоря секций отображены в sticky-TOC (на мобильном —
`<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API.
empty-state. Навигация по секциям — sticky icon-popup в правом
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
который сейчас в зоне видимости, выбор пункта меню — скролл к
нужной секции. При возврате в активный вью отчёт перемонтируется,
позиция скролла сбрасывается к началу, а IntersectionObserver
заново рассчитывает подсветку при прокрутке.
Секция бомбардировок — это плоская read-only-таблица: одна строка на
событие, колонки `attacker`, `attack_power`, признак `wiped` и
+46 -29
View File
@@ -15,7 +15,7 @@ section column.
```
report.svelte
├── report/report-toc.svelte // anchor list + mobile <select>
├── report/report-toc.svelte // sticky icon-popup section menu
├── report/section-galaxy-summary.svelte
├── report/section-votes.svelte
├── report/section-player-status.svelte
@@ -85,27 +85,35 @@ the body — both surfaces stay in sync by construction.
## Table of contents and active highlight
`report/report-toc.svelte` renders two surfaces driven by the same
entry list:
`report/report-toc.svelte` renders a single sticky icon-popup
trigger in the top-right corner of the report column. The trigger
shows `≡` followed by the title of the currently-active section
(CSS-clamped with `text-overflow: ellipsis` so a long RU title
cannot bloat the button). Clicking opens an anchored popover
(`role="menu"`) that lists every section as a `role="menuitem"`
button; the active item gets `aria-current="location"` and the
`.active` class. A click on an item closes the popover and scrolls
the matching `<section id="report-<slug>">` into view via
`scrollIntoView` (with `prefers-reduced-motion` falling back to
`behavior: "auto"`).
- **Desktop / tablet sidebar** — sticky `<aside>` with vertical
anchor list. The anchor for the currently-visible section gets
`aria-current="location"` and an `.active` CSS class.
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS
and a sticky `<select>` takes its place at the top of the body.
Picking an option scrolls the matching section into view. The
mobile contract intentionally avoids stacking another overlay on
top of the existing layout-owned bottom-tabs.
On viewports below `768.98 px` the same surface re-styles into a
fixed bottom-sheet anchored above the layout-owned bottom-tabs
bar (mirrors `lib/active-view/map-toggles.svelte`), so the same
trigger and the same menuitem list serve desktop and mobile.
Both surfaces also expose a "Back to map" affordance
(`report-back-to-map`) at the top.
Open/close state matches the `map-toggles.svelte` precedent:
Escape closes, outside click closes, item-pick closes. The
`restoreFocus` action returns keyboard focus to the trigger on
dismount. The menu is non-modal — no focus trap.
The active slug is computed by an `IntersectionObserver` rooted on
the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`.
The skew biases the active band toward the upper third of the
visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on
unmount.
unmount. The TOC itself owns no observer — the orchestrator passes
`activeSlug` in as a prop.
The in-game shell (`lib/game/game-shell.svelte`)
expands `<main class="active-view-host">` to fit content rather
@@ -129,8 +137,10 @@ highlight stays consistent without a separate source of truth.
All strings live under `game.report.*`:
- `game.report.loading` — section loading placeholder.
- `game.report.back_to_map`, `game.report.toc.title`,
`game.report.toc.mobile_label` — shell-level strings.
- `game.report.toc.title``aria-label` on the TOC root;
`game.report.toc.open` / `game.report.toc.close``aria-label`
on the trigger button, swapped by the open state (mirrors
`game.map.toggles.open` / `close`).
- `game.report.section.<slug>.title` — section heading.
- `game.report.section.<slug>.empty` — empty-state copy (where
applicable).
@@ -151,19 +161,26 @@ couple them silently.
shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid
with conditional row state (`report-section-bombings.test.ts`),
per-race sub-table (`report-section-foreign-sciences.test.ts`),
TOC (`report-toc.test.ts`). Each spec mounts the component
against a synthetic `RenderedReportSource`, so the orchestrator
/ IntersectionObserver are out of scope.
TOC (`report-toc.test.ts`). The TOC spec exercises the icon-popup
state machine (trigger label, open/close, menuitem list, active
highlight, scroll-on-pick, Escape, outside-click). Each spec
mounts the component against a synthetic `RenderedReportSource`,
so the orchestrator / IntersectionObserver are out of scope.
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in
view, the back-to-map button switches to the map view
(`activeView.select("map")`), and the mobile `<select>` scrolls
to the chosen section on a narrow viewport. The spec drives the
app-shell through `window.__galaxyNav` (the dev-only nav surface)
instead of `page.goto` per-view URLs. The old "scroll position
survives a `/map` round-trip via SvelteKit `Snapshot`" case was
dropped — see the [scroll position](#scroll-position) note.
the full integration: every popover menuitem click lands its
section in view and closes the popover, and on the
`chromium-mobile` project the same trigger surfaces a fixed
bottom-sheet popover instead of the anchored desktop variant.
The spec drives the app-shell through `window.__galaxyNav` (the
dev-only nav surface) instead of `page.goto` per-view URLs. The
old "scroll position survives a `/map` round-trip via SvelteKit
`Snapshot`" case was dropped — see the
[scroll position](#scroll-position) note. F8-09 also removed the
in-report "Back to map" test; the same affordance is exercised
through the app-shell view menu by `tests/e2e/game-shell.spec.ts`
("header view-menu navigates to every active view").
Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row
identifiers (e.g. `report-bombing-row`, `my-planets-row`).
roots, `report-toc-trigger` / `report-toc-surface` for the popup
shell, `report-toc-item-<slug>` for each menuitem, and per-section
row identifiers (e.g. `report-bombing-row`, `my-planets-row`).
+2 -20
View File
@@ -135,20 +135,11 @@ TOC and the body iterate the same data.
<style>
.report-view {
display: grid;
grid-template-columns: 14rem 1fr;
gap: 1.25rem;
display: flex;
flex-direction: column;
padding: 1rem 1.25rem 2rem;
font-family: system-ui, sans-serif;
}
.report-view > :global(.report-toc) {
position: sticky;
top: 0;
align-self: start;
padding: 0.5rem 0;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
.report-body {
min-width: 0;
display: flex;
@@ -157,16 +148,7 @@ TOC and the body iterate the same data.
}
@media (max-width: 767.98px) {
.report-view {
grid-template-columns: 1fr;
padding: 0.75rem;
gap: 0.75rem;
}
.report-view > :global(.report-toc) {
position: sticky;
top: 0;
background: var(--color-bg);
padding: 0.5rem 0;
z-index: 5;
}
}
</style>
@@ -1,28 +1,34 @@
<!--
Phase 23 Report View table of contents.
Turn-report section menu.
Responsibilities:
- "Back to map" button at the top — visible on both desktop sidebar
and mobile sticky toolbar. Switches the active view to the map
through `activeView.select("map")`; the shell's tool gate resets
the `mobileTool` overlay naturally once the map is no longer the
active view.
- Desktop / tablet sidebar: a vertical list of anchor links, one per
section. The active link gets `aria-current="location"` and a
`.active` style. Click scrolls the active-view-host (not the
window) by calling `scrollIntoView` on the matching section.
- Mobile (`max-width: 767.98px`): the sidebar collapses to a sticky
`<select>` at the top of the body — a minimal contract that does
not stack with the layout's bottom-tab bar. The same option list
drives both surfaces.
Replaces the F8-09 era 14 rem sticky sidebar (plus its mobile
`<select>` twin) with a single sticky icon-popup trigger that sits
in the top-right corner of the report column. The button shows
`≡` + the title of the section currently in view, derived from the
`activeSlug` prop the orchestrator computes through its
`IntersectionObserver`. Clicking the button opens an anchored menu
(or a bottom-sheet on `<768.98 px`) that lists every section; a
click on an item closes the menu and scrolls the matching section
into view via `scrollIntoView`. The active section gets
`aria-current="location"` plus the `.active` style.
The active section is computed by the orchestrator
(`report.svelte`) via `IntersectionObserver` and passed in via the
`activeSlug` prop. The TOC itself owns no observers.
The trigger label is clamped with `text-overflow: ellipsis` so a
long RU title cannot stretch the button. The popover uses the same
open/close state machine as `lib/active-view/map-toggles.svelte`:
outside-click + Escape close, focus is restored to the trigger
through the `restoreFocus` action. No focus trap — the menu is
non-modal.
The component owns no IntersectionObserver and never sets
`activeSlug` itself. The orchestrator picks the new active slug up
naturally as the target section enters the viewport during the
smooth scroll. The previous "Back to map" affordance was dropped
in F8-09: that switch is available in the app-shell view menu.
-->
<script lang="ts">
import { activeView } from "$lib/app-nav.svelte";
import { onMount } from "svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
export interface TocEntry {
@@ -37,6 +43,16 @@ The active section is computed by the orchestrator
let { entries, activeSlug }: Props = $props();
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const activeEntry = $derived(
entries.find((e) => e.slug === activeSlug) ?? entries[0],
);
const activeTitle = $derived(
activeEntry ? i18n.t(activeEntry.titleKey) : "",
);
function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`);
if (target === null) return;
@@ -49,154 +65,173 @@ The active section is computed by the orchestrator
});
}
function onAnchorClick(event: MouseEvent, slug: string): void {
event.preventDefault();
scrollToSlug(slug);
function pickSection(slug: string): void {
open = false;
// Defer the scroll one frame so Svelte commits the close and
// `restoreFocus` returns focus to the trigger first. Without
// this, the focus restoration (the trigger is sticky, but its
// natural in-flow position is at the top of the report) can
// reset the smooth/instant scroll the browser had just begun.
requestAnimationFrame(() => scrollToSlug(slug));
}
function onSelectChange(event: Event): void {
const select = event.currentTarget as HTMLSelectElement;
const slug = select.value;
if (slug === "") return;
scrollToSlug(slug);
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
function backToMap(): void {
activeView.select("map");
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<aside
<div
class="report-toc"
bind:this={rootEl}
data-testid="report-toc"
aria-label={i18n.t("game.report.toc.title")}
>
<button
type="button"
class="back-to-map"
data-testid="report-back-to-map"
onclick={() => void backToMap()}
class="trigger"
data-testid="report-toc-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.report.toc.close")
: i18n.t("game.report.toc.open")}
onclick={() => (open = !open)}
>
{i18n.t("game.report.back_to_map")}
<span class="icon" aria-hidden="true"></span>
<span class="label">{activeTitle}</span>
</button>
<nav class="desktop" aria-label={i18n.t("game.report.toc.title")}>
<ul>
{#if open}
<div
class="surface"
role="menu"
data-testid="report-toc-surface"
use:restoreFocus
>
{#each entries as entry (entry.slug)}
<li>
<a
href={`#report-${entry.slug}`}
<button
type="button"
role="menuitem"
class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug
? "location"
: undefined}
data-testid="report-toc-{entry.slug}"
onclick={(e) => onAnchorClick(e, entry.slug)}
data-testid="report-toc-item-{entry.slug}"
onclick={() => pickSection(entry.slug)}
>
{i18n.t(entry.titleKey)}
</a>
</li>
</button>
{/each}
</ul>
</nav>
<label class="mobile">
<span class="visually-hidden">
{i18n.t("game.report.toc.mobile_label")}
</span>
<select
data-testid="report-toc-mobile"
value={activeSlug}
onchange={onSelectChange}
>
{#each entries as entry (entry.slug)}
<option value={entry.slug}>{i18n.t(entry.titleKey)}</option>
{/each}
</select>
</label>
</aside>
</div>
{/if}
</div>
<style>
.report-toc {
display: flex;
flex-direction: column;
gap: 0.75rem;
position: sticky;
top: 0.5rem;
align-self: flex-end;
margin-left: auto;
z-index: 30;
font-family: system-ui, sans-serif;
}
.back-to-map {
.trigger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
max-width: min(18rem, calc(100vw - 2rem));
background: var(--color-surface-overlay);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
font: inherit;
font-size: 0.9rem;
}
.trigger:hover {
background: var(--color-surface-hover);
}
.trigger .icon {
flex: 0 0 auto;
font-size: 1.1rem;
line-height: 1;
}
.trigger .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 16rem;
max-height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
gap: 0.1rem;
background: var(--color-surface-overlay);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
padding: 0.35rem;
overflow-y: auto;
z-index: 50;
}
.surface [role="menuitem"] {
text-align: left;
background: transparent;
color: var(--color-text-muted);
border: 0;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
padding: 0.3rem 0.6rem;
font: inherit;
font-size: 0.85rem;
text-align: left;
padding: 0.4rem 0.6rem;
background: var(--color-surface);
color: var(--color-accent);
border: 1px solid var(--color-border);
border-radius: 3px;
line-height: 1.3;
cursor: pointer;
}
.back-to-map:hover {
.surface [role="menuitem"]:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.desktop {
display: block;
}
.desktop ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.desktop a {
display: block;
padding: 0.3rem 0.6rem;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.85rem;
line-height: 1.3;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
}
.desktop a:hover {
color: var(--color-text);
background: var(--color-surface);
}
.desktop a.active {
.surface [role="menuitem"].active {
color: var(--color-text);
background: var(--color-surface);
border-left-color: var(--color-accent);
}
.mobile {
display: none;
}
.mobile select {
width: 100%;
font: inherit;
padding: 0.4rem 0.5rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 767.98px) {
.desktop {
display: none;
}
.mobile {
display: block;
.surface {
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 3.25rem;
max-height: calc(100vh - 6rem);
border-radius: 0;
border-left: 0;
border-right: 0;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
}
}
</style>
+2 -2
View File
@@ -603,9 +603,9 @@ const en = {
"game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.report.loading": "loading report…",
"game.report.back_to_map": "back to map",
"game.report.toc.title": "sections",
"game.report.toc.mobile_label": "jump to section",
"game.report.toc.open": "show section list",
"game.report.toc.close": "hide section list",
"game.report.section.galaxy_summary.title": "galaxy summary",
"game.report.section.galaxy_summary.field.turn": "turn",
"game.report.section.galaxy_summary.field.size": "map size",
+2 -2
View File
@@ -604,9 +604,9 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.report.loading": "загрузка отчёта…",
"game.report.back_to_map": "назад к карте",
"game.report.toc.title": "разделы",
"game.report.toc.mobile_label": "перейти к разделу",
"game.report.toc.open": "показать список разделов",
"game.report.toc.close": "скрыть список разделов",
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
"game.report.section.galaxy_summary.field.turn": "ход",
"game.report.section.galaxy_summary.field.size": "размер карты",
+44 -38
View File
@@ -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();
+4 -2
View File
@@ -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", () => {
+115 -104
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,64 +23,11 @@ const ENTRIES: readonly TocEntry[] = [
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
];
beforeEach(() => {
i18n.resetForTests("en");
activeViewSelectMock.mockClear();
});
describe("report TOC", () => {
test("renders one anchor per entry and one option in the mobile select", () => {
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");
});
test("marks the active anchor with aria-current=location and a class", () => {
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "bombings" },
});
const active = ui.getByTestId("report-toc-bombings");
expect(active).toHaveAttribute("aria-current", "location");
expect(active).toHaveClass("active");
const inactive = ui.getByTestId("report-toc-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 () => {
const ui = render(ReportToc, {
props: {
entries: ENTRIES,
activeSlug: "galaxy-summary",
},
});
const button = ui.getByTestId("report-back-to-map");
await fireEvent.click(button);
expect(activeViewSelectMock).toHaveBeenCalledWith("map");
});
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.
const scrollSpy = vi.fn();
const target = document.createElement("section");
target.id = "report-bombings";
target.scrollIntoView = scrollSpy;
document.body.appendChild(target);
function stubReducedMotion(reduce: boolean): void {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: query.includes("reduce"),
matches: reduce && query.includes("reduce"),
media: query,
onchange: null,
addListener: () => {},
@@ -98,57 +37,129 @@ describe("report TOC", () => {
dispatchEvent: () => false,
}),
});
}
beforeEach(() => {
i18n.resetForTests("en");
});
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" },
});
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("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" },
});
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-item-votes");
expect(inactive).not.toHaveAttribute("aria-current");
expect(inactive).not.toHaveClass("active");
});
test("trigger label tracks activeSlug across re-renders", async () => {
const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
});
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("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);
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;