Files
galaxy-game/ui/frontend/src/lib/header/view-menu.svelte
T
Ilia Denisov 642c5b7322
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m9s
feat(ui): accessibility pass — WCAG 2.2 AA for login/lobby/shell (F2)
Add the a11y foundation and bring login, lobby, and the in-game shell to
WCAG 2.2 AA:

- Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus
  trap + restore) and restoreFocus (menu focus restore) actions, the
  --color-focus visible ring.
- In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar
  tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape +
  focus restore (account / view / turn-navigator / map-toggles /
  bottom-tabs); mail compose as a role=dialog modal with a focus trap.
- login / lobby / lobby-create: skip link + main landmark, field labels,
  role=alert / role=status live regions.
- Map canvas: aria-label naming it a visual overview, with its data
  reachable by keyboard via the sidebar inspector and tables (accessible
  alternative; in-canvas keyboard nav deferred).

Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every
top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts
covers the skip link, menu Escape+restore, and tab roving. Adds
@axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2
done in ui/PLAN-finalize.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:25:14 +02:00

277 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 seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a
navigation. Phase 26 introduces the history-mode entry; Phase 35
polishes microcopy.
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
type Props = { gameId: string };
let { gameId }: Props = $props();
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" },
];
function toggleOpen(): void {
open = !open;
}
function go(path: string): void {
open = false;
void goto(path);
}
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={() => go(`/games/${gameId}/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={() => go(`/games/${gameId}/table/${entry.slug}`)}
>
{i18n.t(entry.key)}
</button>
{/each}
</div>
</details>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)}
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-mail"
class="with-badge"
onclick={() => go(`/games/${gameId}/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={() => go(`/games/${gameId}/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>