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