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 ship classes, battles, bombings, approaching groups, my /
foreign / uninhabited / unknown planets, ships in production, foreign / uninhabited / unknown planets, ships in production,
cargo routes, my fleets, my / foreign / unidentified ship groups). cargo routes, my fleets, my / foreign / unidentified ship groups).
Empty sections render explicit empty-state copy. Section anchors Empty sections render explicit empty-state copy. Section
are exposed in a sticky table of contents (a `<select>` on mobile) navigation is exposed through a sticky icon-popup menu pinned to
and the scroll position is preserved across active-view switches the top-right of the report column (an anchored popover on desktop
via SvelteKit's `Snapshot` API. 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 The Bombings section is a flat read-only table — one row per
bombing event, columns for `attacker`, `attack_power`, `wiped` bombing event, columns for `attacker`, `attack_power`, `wiped`
+7 -3
View File
@@ -713,9 +713,13 @@ Web-клиент рендерит отчёт как одну секцию на
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
производстве, грузовые маршруты, мои флоты, мои / чужие / производстве, грузовые маршруты, мои флоты, мои / чужие /
неопознанные группы кораблей). Пустые секции получают явную копию неопознанные группы кораблей). Пустые секции получают явную копию
empty-state. Якоря секций отображены в sticky-TOC (на мобильном — empty-state. Навигация по секциям — sticky icon-popup в правом
`<select>`); позиция скролла сохраняется при переключении активного верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
представления через SvelteKit `Snapshot` API. bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
который сейчас в зоне видимости, выбор пункта меню — скролл к
нужной секции. При возврате в активный вью отчёт перемонтируется,
позиция скролла сбрасывается к началу, а IntersectionObserver
заново рассчитывает подсветку при прокрутке.
Секция бомбардировок — это плоская read-only-таблица: одна строка на Секция бомбардировок — это плоская read-only-таблица: одна строка на
событие, колонки `attacker`, `attack_power`, признак `wiped` и событие, колонки `attacker`, `attack_power`, признак `wiped` и
+46 -29
View File
@@ -15,7 +15,7 @@ section column.
``` ```
report.svelte 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-galaxy-summary.svelte
├── report/section-votes.svelte ├── report/section-votes.svelte
├── report/section-player-status.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 ## Table of contents and active highlight
`report/report-toc.svelte` renders two surfaces driven by the same `report/report-toc.svelte` renders a single sticky icon-popup
entry list: 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 On viewports below `768.98 px` the same surface re-styles into a
anchor list. The anchor for the currently-visible section gets fixed bottom-sheet anchored above the layout-owned bottom-tabs
`aria-current="location"` and an `.active` CSS class. bar (mirrors `lib/active-view/map-toggles.svelte`), so the same
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS trigger and the same menuitem list serve desktop and mobile.
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.
Both surfaces also expose a "Back to map" affordance Open/close state matches the `map-toggles.svelte` precedent:
(`report-back-to-map`) at the top. 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 active slug is computed by an `IntersectionObserver` rooted on
the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`. the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`.
The skew biases the active band toward the upper third of the The skew biases the active band toward the upper third of the
visible area so that scrolling down advances the highlight visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on 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`) The in-game shell (`lib/game/game-shell.svelte`)
expands `<main class="active-view-host">` to fit content rather 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.*`: All strings live under `game.report.*`:
- `game.report.loading` — section loading placeholder. - `game.report.loading` — section loading placeholder.
- `game.report.back_to_map`, `game.report.toc.title`, - `game.report.toc.title``aria-label` on the TOC root;
`game.report.toc.mobile_label` — shell-level strings. `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>.title` — section heading.
- `game.report.section.<slug>.empty` — empty-state copy (where - `game.report.section.<slug>.empty` — empty-state copy (where
applicable). applicable).
@@ -151,19 +161,26 @@ couple them silently.
shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid
with conditional row state (`report-section-bombings.test.ts`), with conditional row state (`report-section-bombings.test.ts`),
per-race sub-table (`report-section-foreign-sciences.test.ts`), per-race sub-table (`report-section-foreign-sciences.test.ts`),
TOC (`report-toc.test.ts`). Each spec mounts the component TOC (`report-toc.test.ts`). The TOC spec exercises the icon-popup
against a synthetic `RenderedReportSource`, so the orchestrator state machine (trigger label, open/close, menuitem list, active
/ IntersectionObserver are out of scope. 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 - **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in the full integration: every popover menuitem click lands its
view, the back-to-map button switches to the map view section in view and closes the popover, and on the
(`activeView.select("map")`), and the mobile `<select>` scrolls `chromium-mobile` project the same trigger surfaces a fixed
to the chosen section on a narrow viewport. The spec drives the bottom-sheet popover instead of the anchored desktop variant.
app-shell through `window.__galaxyNav` (the dev-only nav surface) The spec drives the app-shell through `window.__galaxyNav` (the
instead of `page.goto` per-view URLs. The old "scroll position dev-only nav surface) instead of `page.goto` per-view URLs. The
survives a `/map` round-trip via SvelteKit `Snapshot`" case was old "scroll position survives a `/map` round-trip via SvelteKit
dropped — see the [scroll position](#scroll-position) note. `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 Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row roots, `report-toc-trigger` / `report-toc-surface` for the popup
identifiers (e.g. `report-bombing-row`, `my-planets-row`). 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> <style>
.report-view { .report-view {
display: grid; display: flex;
grid-template-columns: 14rem 1fr; flex-direction: column;
gap: 1.25rem;
padding: 1rem 1.25rem 2rem; padding: 1rem 1.25rem 2rem;
font-family: system-ui, sans-serif; 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 { .report-body {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -157,16 +148,7 @@ TOC and the body iterate the same data.
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.report-view { .report-view {
grid-template-columns: 1fr;
padding: 0.75rem; 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> </style>
@@ -1,28 +1,34 @@
<!-- <!--
Phase 23 Report View table of contents. Turn-report section menu.
Responsibilities: Replaces the F8-09 era 14 rem sticky sidebar (plus its mobile
- "Back to map" button at the top — visible on both desktop sidebar `<select>` twin) with a single sticky icon-popup trigger that sits
and mobile sticky toolbar. Switches the active view to the map in the top-right corner of the report column. The button shows
through `activeView.select("map")`; the shell's tool gate resets `≡` + the title of the section currently in view, derived from the
the `mobileTool` overlay naturally once the map is no longer the `activeSlug` prop the orchestrator computes through its
active view. `IntersectionObserver`. Clicking the button opens an anchored menu
- Desktop / tablet sidebar: a vertical list of anchor links, one per (or a bottom-sheet on `<768.98 px`) that lists every section; a
section. The active link gets `aria-current="location"` and a click on an item closes the menu and scrolls the matching section
`.active` style. Click scrolls the active-view-host (not the into view via `scrollIntoView`. The active section gets
window) by calling `scrollIntoView` on the matching section. `aria-current="location"` plus the `.active` style.
- 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.
The active section is computed by the orchestrator The trigger label is clamped with `text-overflow: ellipsis` so a
(`report.svelte`) via `IntersectionObserver` and passed in via the long RU title cannot stretch the button. The popover uses the same
`activeSlug` prop. The TOC itself owns no observers. 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"> <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"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
export interface TocEntry { export interface TocEntry {
@@ -37,6 +43,16 @@ The active section is computed by the orchestrator
let { entries, activeSlug }: Props = $props(); 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 { function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`); const target = document.getElementById(`report-${slug}`);
if (target === null) return; if (target === null) return;
@@ -49,154 +65,173 @@ The active section is computed by the orchestrator
}); });
} }
function onAnchorClick(event: MouseEvent, slug: string): void { function pickSection(slug: string): void {
event.preventDefault(); open = false;
scrollToSlug(slug); // 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 { function onKeyDown(event: KeyboardEvent): void {
const select = event.currentTarget as HTMLSelectElement; if (event.key === "Escape" && open) {
const slug = select.value; open = false;
if (slug === "") return; }
scrollToSlug(slug);
} }
function backToMap(): void { onMount(() => {
activeView.select("map"); 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> </script>
<aside <div
class="report-toc" class="report-toc"
bind:this={rootEl}
data-testid="report-toc" data-testid="report-toc"
aria-label={i18n.t("game.report.toc.title")} aria-label={i18n.t("game.report.toc.title")}
> >
<button <button
type="button" type="button"
class="back-to-map" class="trigger"
data-testid="report-back-to-map" data-testid="report-toc-trigger"
onclick={() => void backToMap()} 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> </button>
{#if open}
<nav class="desktop" aria-label={i18n.t("game.report.toc.title")}> <div
<ul> class="surface"
role="menu"
data-testid="report-toc-surface"
use:restoreFocus
>
{#each entries as entry (entry.slug)} {#each entries as entry (entry.slug)}
<li> <button
<a type="button"
href={`#report-${entry.slug}`} role="menuitem"
class:active={activeSlug === entry.slug} class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug aria-current={activeSlug === entry.slug
? "location" ? "location"
: undefined} : undefined}
data-testid="report-toc-{entry.slug}" data-testid="report-toc-item-{entry.slug}"
onclick={(e) => onAnchorClick(e, entry.slug)} onclick={() => pickSection(entry.slug)}
> >
{i18n.t(entry.titleKey)} {i18n.t(entry.titleKey)}
</a> </button>
</li>
{/each} {/each}
</ul> </div>
</nav> {/if}
</div>
<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>
<style> <style>
.report-toc { .report-toc {
display: flex; position: sticky;
flex-direction: column; top: 0.5rem;
gap: 0.75rem; align-self: flex-end;
margin-left: auto;
z-index: 30;
font-family: system-ui, sans-serif; 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: inherit;
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; line-height: 1.3;
padding: 0.4rem 0.6rem;
background: var(--color-surface);
color: var(--color-accent);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
.back-to-map:hover { .surface [role="menuitem"]:hover {
background: var(--color-surface-hover); background: var(--color-surface-hover);
color: var(--color-text); color: var(--color-text);
} }
.desktop { .surface [role="menuitem"].active {
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 {
color: var(--color-text); color: var(--color-text);
background: var(--color-surface); background: var(--color-surface);
border-left-color: var(--color-accent); 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) { @media (max-width: 767.98px) {
.desktop { .surface {
display: none; position: fixed;
} top: auto;
.mobile { left: 0;
display: block; 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> </style>
+2 -2
View File
@@ -603,9 +603,9 @@ const en = {
"game.inspector.planet.ship_groups.race.unknown": "unknown", "game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.report.loading": "loading report…", "game.report.loading": "loading report…",
"game.report.back_to_map": "back to map",
"game.report.toc.title": "sections", "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.title": "galaxy summary",
"game.report.section.galaxy_summary.field.turn": "turn", "game.report.section.galaxy_summary.field.turn": "turn",
"game.report.section.galaxy_summary.field.size": "map size", "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.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.report.loading": "загрузка отчёта…", "game.report.loading": "загрузка отчёта…",
"game.report.back_to_map": "назад к карте",
"game.report.toc.title": "разделы", "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.title": "общие сведения о галактике",
"game.report.section.galaxy_summary.field.turn": "ход", "game.report.section.galaxy_summary.field.turn": "ход",
"game.report.section.galaxy_summary.field.size": "размер карты", "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 orchestrator's sections render, then drives the page through
// the targeted-test contract: // the targeted-test contract:
// //
// 1. Every TOC anchor click scrolls the matching section into view // 1. Every TOC menuitem click scrolls the matching section into
// and the section is present in the DOM with at least one row // view and the section is present in the DOM with at least one
// (or its empty-state copy when it is intentionally empty). // row (or its empty-state copy when it is intentionally empty).
// 2. The "back to map" button switches to the map view. // 2. On a narrow viewport the same trigger surfaces a bottom-sheet
// 3. The mobile <select> fallback scrolls a section into view on // popover and the same menuitem flow lands the chosen section.
// a narrow viewport. //
// 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 { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test"; 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. // SECTIONS lists every TOC slug paired with a row-presence hook.
// `expectRow` is null for sections that the seeded report // `expectRow` is null for sections that the seeded report
// intentionally leaves empty so the empty-state copy is asserted // intentionally leaves empty so the empty-state copy is asserted
// instead. The orchestrator's section order must match this // instead. The orchestrator's section order must match this list —
// list — the spec relies on each slug having a `report-toc-<slug>` // the spec relies on each slug having a `report-toc-item-<slug>` in
// and a `report-section-<slug>` testid. // the popover and a `report-section-<slug>` testid in the body.
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [ const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" }, { slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
{ slug: "votes", expectRow: "votes-mine" }, { slug: "votes", expectRow: "votes-mine" },
@@ -226,7 +230,7 @@ async function bootSession(page: Page): Promise<void> {
} }
test.describe("Phase 23 report view", () => { 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, page,
}, testInfo) => { }, testInfo) => {
test.skip( test.skip(
@@ -252,9 +256,16 @@ test.describe("Phase 23 report view", () => {
page.getByTestId("galaxy-summary-field-turn"), page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible(); ).toBeVisible();
const trigger = page.getByTestId("report-toc-trigger");
const surface = page.getByTestId("report-toc-surface");
for (const entry of SECTIONS) { for (const entry of SECTIONS) {
const anchor = page.getByTestId(`report-toc-${entry.slug}`); await trigger.click();
await anchor.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}`); const section = page.getByTestId(`report-section-${entry.slug}`);
await expect(section).toBeInViewport(); await expect(section).toBeInViewport();
if (entry.expectRow !== null) { 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 // scroll-restoration the test asserted exist any more. Re-adding that
// behaviour would be a production change outside this test migration. // behaviour would be a production change outside this test migration.
test("back-to-map button switches to the map view", async ({ // F8-09 removed the in-report "Back to map" button — the same
page, // affordance lives in the app-shell view menu, exercised by
}, testInfo) => { // `game-shell.spec.ts` ("header view-menu navigates to every
test.skip( // active view").
testInfo.project.name.startsWith("chromium-mobile"),
"navigation is identical on mobile",
);
await mockGateway(page); test("mobile bottom-sheet popover lands the chosen section", async ({
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 ({
page, page,
}, testInfo) => { }, testInfo) => {
test.skip( test.skip(
@@ -320,12 +313,25 @@ test.describe("Phase 23 report view", () => {
GAME_ID, GAME_ID,
); );
const mobileSelect = page.getByTestId("report-toc-mobile");
await expect(mobileSelect).toBeVisible();
await expect( await expect(
page.getByTestId("galaxy-summary-field-turn"), page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible(); ).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( await expect(
page.getByTestId("report-section-bombings"), page.getByTestId("report-section-bombings"),
).toBeInViewport(); ).toBeInViewport();
+4 -2
View File
@@ -63,17 +63,19 @@ describe("active-view stubs", () => {
expect(node).toHaveTextContent("ship groups"); 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 // Phase 23 replaces the Phase 10 stub with the full report
// orchestrator. The orchestrator mounts the table of contents // orchestrator. The orchestrator mounts the table of contents
// regardless of report state; the inner sections render // regardless of report state; the inner sections render
// loading copy until a `RenderedReportSource` lands via // loading copy until a `RenderedReportSource` lands via
// context. This test only smokes the orchestrator scaffold — // context. This test only smokes the orchestrator scaffold —
// per-section assertions live in `report-section-*.test.ts`. // 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); const r = render(ReportView);
expect(r.getByTestId("active-view-report")).toBeInTheDocument(); expect(r.getByTestId("active-view-report")).toBeInTheDocument();
expect(r.getByTestId("report-toc")).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", () => { 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. // Vitest coverage for the F8-09 turn-report icon-popup section menu.
// Smokes the anchor list, the active-link state, the back-to-map // Smokes the sticky trigger, its active-title label, the open/close
// navigation, and the mobile <select> fallback. The // state machine, popover items + active highlight, scroll-on-pick,
// IntersectionObserver-driven active-section computation lives in // and the Escape + outside-click dismissal paths. The
// the orchestrator (`report.svelte`); this test only checks the // IntersectionObserver-driven `activeSlug` is computed by the
// presentational pieces of the TOC. // orchestrator (`report.svelte`) and passed in via prop; the TOC
// itself owns no observers.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; 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 { i18n } from "../src/lib/i18n/index.svelte";
import type { TranslationKey } 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, { import ReportToc, {
type TocEntry, type TocEntry,
} from "../src/lib/active-view/report/report-toc.svelte"; } 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" }, { slug: "bombings", titleKey: "game.report.section.bombings.title" },
]; ];
beforeEach(() => { function stubReducedMotion(reduce: boolean): void {
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);
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
value: (query: string) => ({ value: (query: string) => ({
matches: query.includes("reduce"), matches: reduce && query.includes("reduce"),
media: query, media: query,
onchange: null, onchange: null,
addListener: () => {}, addListener: () => {},
@@ -98,57 +37,129 @@ describe("report TOC", () => {
dispatchEvent: () => false, 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, { const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, 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({ expect(scrollSpy).toHaveBeenCalledWith({
behavior: "auto", behavior: "auto",
block: "start", block: "start",
}); });
expect(ui.queryByTestId("report-toc-surface")).toBeNull();
expect(ui.getByTestId("report-toc-trigger")).toHaveAttribute(
"aria-expanded",
"false",
);
target.remove(); target.remove();
}); });
test("mobile select scrolls to the chosen section without navigating", async () => { test("Escape closes the popover", async () => {
const scrollSpy = vi.fn(); const ui = render(ReportToc, {
const target = document.createElement("section"); props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
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,
}),
}); });
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, { const ui = render(ReportToc, {
props: { props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
entries: ENTRIES,
activeSlug: "galaxy-summary",
},
}); });
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement; await fireEvent.click(ui.getByTestId("report-toc-trigger"));
await fireEvent.change(select, { target: { value: "votes" } }); expect(ui.getByTestId("report-toc-surface")).toBeInTheDocument();
expect(scrollSpy).toHaveBeenCalled(); await fireEvent.click(outside);
expect(activeViewSelectMock).not.toHaveBeenCalled(); expect(ui.queryByTestId("report-toc-surface")).toBeNull();
target.remove();
outside.remove();
}); });
// Tests intentionally validate the *type* of the entries prop is // Soft type-level check: a stray `as unknown as ...` cast on
// exposed correctly so future widening of the list does not // `TocEntry` would silently widen the prop shape — assert the
// silently drop entries. TypeScript already enforces this through // runtime values look as expected.
// `TocEntry`; the assertion below is a soft check so a stray
// `as unknown as ...` cast surfaces fast.
test("TocEntry exposes a slug and a TranslationKey", () => { test("TocEntry exposes a slug and a TranslationKey", () => {
const slug: string = ENTRIES[0]!.slug; const slug: string = ENTRIES[0]!.slug;
const key: TranslationKey = ENTRIES[0]!.titleKey; const key: TranslationKey = ENTRIES[0]!.titleKey;