642c5b7322
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>
277 lines
6.6 KiB
Svelte
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>
|