feat(ui): F8-09 — turn report sticky icon-popup section menu #67
+9
-4
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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`).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "размер карты",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user