feat(ui): F8-09 — turn report sticky icon-popup section menu (#52)
- 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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user