feat(ui): F8-09 — turn report sticky icon-popup section menu #67

Merged
developer merged 2 commits from feature/issue-52-report-toc-popup into development 2026-05-27 17:52:59 +00:00
10 changed files with 450 additions and 322 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` и
+67 -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,56 @@ 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 icon-popup trigger
entry list: pinned to 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 The trigger uses `position: fixed` instead of `position: sticky`.
anchor list. The anchor for the currently-visible section gets Per the CSS sticky spec a sticky element sticks within its nearest
`aria-current="location"` and an `.active` CSS class. ancestor with non-`visible` overflow — and `.active-view-host`
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS declares `overflow-y: auto` for the mobile scroll story. On
and a sticky `<select>` takes its place at the top of the body. desktop the host grows with content and the document body becomes
Picking an option scrolls the matching section into view. The the actual scroll container, so a sticky trigger inside the report
mobile contract intentionally avoids stacking another overlay on column never receives a scroll event and rides up with the page
top of the existing layout-owned bottom-tabs. content. A fixed trigger sidesteps the chain entirely; the
component is mounted only while the report active view is on
screen, so the fixed element is naturally tied to the view's
lifetime. The desktop offset is `right: calc(18 rem + 1.25 rem)`
to clear the always-on `lib/sidebar/sidebar.svelte`; below
1024 px the sidebar collapses to an overlay drawer, so the
default `right: 1.25 rem` matches the report's right padding. The
report-view itself adds a top padding equal to the trigger's
viewport offset plus its height so the first section's heading
does not render under the trigger at scroll position 0, and every
`<section id="report-…">` gets `scroll-margin-top: 7.5rem` so
`scrollIntoView({ block: "start" })` lands the heading below the
trigger after a popover-driven jump.
Both surfaces also expose a "Back to map" affordance On viewports below `768.98 px` the popover surface re-styles into
(`report-back-to-map`) at the top. 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.
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 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 +158,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 +182,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`).
+17 -22
View File
@@ -134,39 +134,34 @@ TOC and the body iterate the same data.
</div> </div>
<style> <style>
/*
`padding-top` clears the fixed TOC trigger that lives in the
viewport's top-right corner (`report/report-toc.svelte`,
`position: fixed; top: 4rem`). Without this padding the first
section (galaxy-summary) renders directly under the trigger.
*/
.report-view { .report-view {
display: grid; padding: 4.5rem 1.25rem 2rem;
grid-template-columns: 14rem 1fr;
gap: 1.25rem;
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;
flex-direction: column; flex-direction: column;
gap: 1.75rem; gap: 1.75rem;
} }
/*
`scroll-margin-top` lets `scrollIntoView({ block: "start" })`
land the section *heading* in view (not behind the fixed
trigger or the sticky in-game header). Budget: ~3 rem header +
~3 rem trigger area + ~1.5 rem breathing room.
*/
.report-body :global(section[id^="report-"]) {
scroll-margin-top: 7.5rem;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.report-view { .report-view {
grid-template-columns: 1fr; padding: 4rem 0.75rem 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,205 @@ 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"
{#each entries as entry (entry.slug)} role="menu"
<li> data-testid="report-toc-surface"
<a use:restoreFocus
href={`#report-${entry.slug}`}
class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug
? "location"
: undefined}
data-testid="report-toc-{entry.slug}"
onclick={(e) => onAnchorClick(e, entry.slug)}
>
{i18n.t(entry.titleKey)}
</a>
</li>
{/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)} {#each entries as entry (entry.slug)}
<option value={entry.slug}>{i18n.t(entry.titleKey)}</option> <button
type="button"
role="menuitem"
class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug
? "location"
: undefined}
data-testid="report-toc-item-{entry.slug}"
onclick={() => pickSection(entry.slug)}
>
{i18n.t(entry.titleKey)}
</button>
{/each} {/each}
</select> </div>
</label> {/if}
</aside> </div>
<style> <style>
/*
The trigger uses `position: fixed` rather than `position: sticky`
because on desktop the in-game shell's `.active-view-host`
declares `overflow-y: auto` while staying tall enough that no
overflow actually engages — the document body scrolls instead.
Per the CSS sticky spec the host is the "scrollport" for any
sticky descendant, so a sticky trigger inside the report column
never receives a scroll event and rides up with the page. Fixed
positioning anchors the trigger to the viewport directly; the
component is only mounted while the report active view is on
screen, so the fixed element is naturally tied to the view's
lifetime.
`top: 4rem` clears the sticky header (~3 rem). On ≥ 1024 px the
sidebar (`lib/sidebar/sidebar.svelte`) is always visible and
occupies the right 18 rem of the viewport, so the trigger has to
be pushed left by that much to stay inside the report column.
Below 1024 px the sidebar collapses into an overlay drawer,
so a viewport-right anchor of 1.25 rem matches the report's
right padding.
When the history banner is showing (historical turn view,
~2 rem extra) the trigger sits at the banner's lower edge —
acceptable for the rare historical-turn read path.
*/
.report-toc { .report-toc {
display: flex; position: fixed;
flex-direction: column; top: 4rem;
gap: 0.75rem; right: 1.25rem;
z-index: 30;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
.back-to-map { @media (min-width: 1024px) {
.report-toc {
right: calc(18rem + 1.25rem);
}
}
.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 { .report-toc {
display: none; right: 0.75rem;
} }
.mobile { .surface {
display: block; 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> </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", () => {
+99 -88
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,124 +23,143 @@ const ENTRIES: readonly TocEntry[] = [
{ slug: "bombings", titleKey: "game.report.section.bombings.title" }, { 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(() => { beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
activeViewSelectMock.mockClear();
}); });
describe("report TOC", () => { describe("report TOC (icon-popup)", () => {
test("renders one anchor per entry and one option in the mobile select", () => { test("renders the trigger with the active section title; popover closed by default", () => {
const ui = render(ReportToc, { const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
}); });
for (const e of ENTRIES) { const trigger = ui.getByTestId("report-toc-trigger");
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument(); expect(trigger).toHaveAttribute("aria-haspopup", "menu");
} expect(trigger).toHaveAttribute("aria-expanded", "false");
const mobile = ui.getByTestId("report-toc-mobile") as HTMLSelectElement; expect(trigger).toHaveTextContent("galaxy summary");
expect(mobile.options).toHaveLength(ENTRIES.length); expect(ui.queryByTestId("report-toc-surface")).toBeNull();
expect(mobile.value).toBe("galaxy-summary");
}); });
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, { const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "bombings" }, 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).toHaveAttribute("aria-current", "location");
expect(active).toHaveClass("active"); 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.toHaveAttribute("aria-current");
expect(inactive).not.toHaveClass("active"); 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, { const ui = render(ReportToc, {
props: { props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
entries: ENTRIES,
activeSlug: "galaxy-summary",
},
}); });
const button = ui.getByTestId("report-back-to-map"); expect(ui.getByTestId("report-toc-trigger")).toHaveTextContent(
await fireEvent.click(button); "galaxy summary",
expect(activeViewSelectMock).toHaveBeenCalledWith("map"); );
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 () => { test("clicking a menuitem scrolls the target into view and closes the popover", async () => {
// Stub `scrollIntoView` on the target — jsdom does not // jsdom does not implement `scrollIntoView`; stub it on the
// implement it. The TOC also reads // section the spec is about to pick. `matchMedia` is forced to
// `prefers-reduced-motion`; the matchMedia stub forces a // `reduce` so the assertion stays stable.
// stable `behavior: "auto"` so the assertion is reproducible.
const scrollSpy = vi.fn(); const scrollSpy = vi.fn();
const target = document.createElement("section"); const target = document.createElement("section");
target.id = "report-bombings"; target.id = "report-bombings";
target.scrollIntoView = scrollSpy; target.scrollIntoView = scrollSpy;
document.body.appendChild(target); document.body.appendChild(target);
stubReducedMotion(true);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: query.includes("reduce"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
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;