Files
galaxy-game/ui/frontend/src/lib/header/view-menu.svelte
T
Ilia Denisov 209f8508cd
Tests · UI / test (push) Successful in 2m53s
Tests · UI / test (pull_request) Successful in 3m0s
feat(ui): F8-11 — battles table under table submenu (#54)
Adds a sortable battles list as a new entity under the existing
`view → table` submenu (entity slug `battles`), replacing the
standalone top-level `battle log` shortcut which always opened a
"battle not found" placeholder. The single-battle viewer stays put
and is reached only by clicking a row (or a battle marker on the
map), identical to the existing `section-battles.svelte` flow.

Columns are planet (via the shared `planetLabel` helper) and shots
(the per-battle action count carried by `BattleSummary`), sortable
both ways with shots-desc default. No backend / FBS / map changes:
the wire payload is unchanged. Participants / observers / total
mass require the full BattleReport and were intentionally dropped
to avoid N round trips per menu open.

The top-level `battle log` item is removed from `header/view-menu`
and `sidebar/bottom-tabs` (and their stale comment blocks updated);
the now-orphan `game.view.battle` i18n key is dropped from both
locales.
2026-05-27 22:12:51 +02:00

272 lines
6.6 KiB
Svelte

<!--
Active-view picker that drives navigation between every in-game view.
The trigger swaps icon between desktop dropdown (▾) and mobile
hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
itself is identical. The same component is reused for the mobile
"More" drawer entry of `bottom-tabs.svelte`.
Lists the IA destinations: map, tables (sub-list of seven entities
including the battles roll-up that replaces the standalone battle-log
shortcut), report, mail, science designer. Each entry mutates
`activeView` (the single-URL app-shell has no per-view routes) and
closes the menu. Closes on Escape, on outside click, and after a
selection. Phase 26 introduces the history-mode entry; microcopy is
refined in a later polish pass.
-->
<script lang="ts">
import { onMount } from "svelte";
import { activeView, type GameView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const tableEntities: ReadonlyArray<{ slug: string; key: TranslationKey }> = [
{ slug: "planets", key: "game.view.table.planets" },
{ slug: "ship-classes", key: "game.view.table.ship_classes" },
{ slug: "ship-groups", key: "game.view.table.ship_groups" },
{ slug: "fleets", key: "game.view.table.fleets" },
{ slug: "sciences", key: "game.view.table.sciences" },
{ slug: "races", key: "game.view.table.races" },
{ slug: "battles", key: "game.view.table.battles" },
];
function toggleOpen(): void {
open = !open;
}
function select(
view: GameView,
params: { tableEntity?: string } = {},
): void {
open = false;
activeView.select(view, params);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
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>
<div class="view-menu" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="view-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.menu.close_views")
: i18n.t("game.shell.menu.open_views")}
onclick={toggleOpen}
>
<span class="icon-dropdown" aria-hidden="true"></span>
<span class="icon-hamburger" aria-hidden="true"></span>
</button>
{#if open}
<div
class="surface"
role="menu"
data-testid="view-menu-list"
use:restoreFocus
>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-map"
onclick={() => select("map")}
>
{i18n.t("game.view.map")}
</button>
<details data-testid="view-menu-tables">
<summary>{i18n.t("game.view.table")}</summary>
<div class="sub">
{#each tableEntities as entry (entry.slug)}
<button
type="button"
role="menuitem"
data-testid="view-menu-item-table-{entry.slug}"
onclick={() => select("table", { tableEntity: entry.slug })}
>
{i18n.t(entry.key)}
</button>
{/each}
</div>
</details>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-report"
onclick={() => select("report")}
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-mail"
class="with-badge"
onclick={() => select("mail")}
>
<span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0}
<span class="badge" data-testid="view-menu-item-mail-badge">
{i18n.t("game.view.mail.badge", { count: String(mailUnread) })}
</span>
{/if}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-designer-science"
onclick={() => select("designer-science")}
>
{i18n.t("game.view.designer.science")}
</button>
</div>
{/if}
</div>
<style>
.view-menu {
position: relative;
}
.trigger {
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
}
.trigger:hover {
background: var(--color-surface-hover);
}
.icon-dropdown {
display: inline;
}
.icon-hamburger {
display: none;
}
@media (max-width: 767.98px) {
.icon-dropdown {
display: none;
}
.icon-hamburger {
display: inline;
}
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 14rem;
display: flex;
flex-direction: column;
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 50;
}
.surface > button,
.surface > details > summary {
text-align: left;
font: inherit;
padding: 0.45rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.surface > button.with-badge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.surface > button.with-badge .badge {
min-width: 1.5rem;
padding: 0 0.4rem;
text-align: center;
font-size: 0.75rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-accent-contrast);
}
.surface > button:hover,
.surface > details > summary:hover {
background: var(--color-surface-hover);
}
.surface > details > summary {
list-style: none;
}
.surface > details > summary::-webkit-details-marker {
display: none;
}
.surface > details > summary::after {
content: "▸";
float: right;
opacity: 0.7;
}
.surface > details[open] > summary::after {
content: "▾";
}
.sub {
display: flex;
flex-direction: column;
padding-left: 0.5rem;
border-left: 1px solid var(--color-border);
margin: 0 0.5rem 0.25rem;
}
.sub > button {
text-align: left;
font: inherit;
padding: 0.35rem 0.5rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.sub > button:hover {
background: var(--color-surface-hover);
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
border-radius: 0;
border-left: 0;
border-right: 0;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
}
</style>