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>
304 lines
7.6 KiB
Svelte
304 lines
7.6 KiB
Svelte
<!--
|
|
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates
|
|
to `/games/:id/map` and resets the tool overlay. Calc and Order also
|
|
navigate to `/games/:id/map` — the layout's tool gate replaces the
|
|
active view with the matching sidebar tool only when the URL is
|
|
`/map`, so navigating to any other view via the More drawer or the
|
|
header view-menu naturally drops the overlay.
|
|
|
|
More opens a drawer with the same destination list as the header
|
|
view-menu. Phase 35 polish narrows it to the IA-spec subset
|
|
(Mail, Battle log, Tables, History, Settings, Logout) once History
|
|
exists; until then the convenience of one source of truth for
|
|
destinations beats the duplication.
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
|
import { restoreFocus } from "$lib/a11y/restore-focus";
|
|
import type { MobileTool } from "./types";
|
|
|
|
type Props = {
|
|
gameId: string;
|
|
activeTool: MobileTool;
|
|
onSelectTool: (tool: MobileTool) => void;
|
|
hideOrder?: boolean;
|
|
};
|
|
let {
|
|
gameId,
|
|
activeTool,
|
|
onSelectTool,
|
|
hideOrder = false,
|
|
}: Props = $props();
|
|
|
|
let moreOpen = $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" },
|
|
];
|
|
|
|
async function selectTool(tool: MobileTool): Promise<void> {
|
|
moreOpen = false;
|
|
onSelectTool(tool);
|
|
await goto(`/games/${gameId}/map`);
|
|
}
|
|
|
|
async function go(path: string): Promise<void> {
|
|
moreOpen = false;
|
|
onSelectTool("map");
|
|
await goto(path);
|
|
}
|
|
|
|
function toggleMore(): void {
|
|
moreOpen = !moreOpen;
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent): void {
|
|
if (event.key === "Escape" && moreOpen) {
|
|
moreOpen = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
const handleClick = (event: MouseEvent): void => {
|
|
if (!moreOpen || rootEl === null) return;
|
|
const target = event.target;
|
|
if (target instanceof Node && rootEl.contains(target)) return;
|
|
moreOpen = false;
|
|
};
|
|
document.addEventListener("click", handleClick, true);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("click", handleClick, true);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="bottom-tabs" data-testid="bottom-tabs" bind:this={rootEl}>
|
|
<div class="tabs" role="tablist" aria-label={i18n.t("game.bottom_tabs.map")}>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
data-testid="bottom-tab-map"
|
|
aria-selected={activeTool === "map"}
|
|
class:active={activeTool === "map"}
|
|
onclick={() => selectTool("map")}
|
|
>
|
|
<span class="icon" aria-hidden="true">▣</span>
|
|
<span class="label">{i18n.t("game.bottom_tabs.map")}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
data-testid="bottom-tab-calc"
|
|
aria-selected={activeTool === "calc"}
|
|
class:active={activeTool === "calc"}
|
|
onclick={() => selectTool("calc")}
|
|
>
|
|
<span class="icon" aria-hidden="true">🧮</span>
|
|
<span class="label">{i18n.t("game.bottom_tabs.calc")}</span>
|
|
</button>
|
|
{#if !hideOrder}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
data-testid="bottom-tab-order"
|
|
aria-selected={activeTool === "order"}
|
|
class:active={activeTool === "order"}
|
|
onclick={() => selectTool("order")}
|
|
>
|
|
<span class="icon" aria-hidden="true">📝</span>
|
|
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
|
|
</button>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
data-testid="bottom-tab-more"
|
|
aria-haspopup="menu"
|
|
aria-expanded={moreOpen}
|
|
class:active={moreOpen}
|
|
onclick={toggleMore}
|
|
>
|
|
<span class="icon" aria-hidden="true">☰</span>
|
|
<span class="label">{i18n.t("game.bottom_tabs.more")}</span>
|
|
</button>
|
|
</div>
|
|
{#if moreOpen}
|
|
<div
|
|
class="drawer"
|
|
role="menu"
|
|
data-testid="bottom-tabs-more-drawer"
|
|
use:restoreFocus
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="bottom-tabs-more-map"
|
|
onclick={() => go(`/games/${gameId}/map`)}
|
|
>
|
|
{i18n.t("game.view.map")}
|
|
</button>
|
|
<details data-testid="bottom-tabs-more-tables">
|
|
<summary>{i18n.t("game.view.table")}</summary>
|
|
<div class="sub">
|
|
{#each tableEntities as entry (entry.slug)}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="bottom-tabs-more-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="bottom-tabs-more-report"
|
|
onclick={() => go(`/games/${gameId}/report`)}
|
|
>
|
|
{i18n.t("game.view.report")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="bottom-tabs-more-battle"
|
|
onclick={() => go(`/games/${gameId}/battle`)}
|
|
>
|
|
{i18n.t("game.view.battle")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="bottom-tabs-more-mail"
|
|
onclick={() => go(`/games/${gameId}/mail`)}
|
|
>
|
|
{i18n.t("game.view.mail")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="bottom-tabs-more-designer-science"
|
|
onclick={() => go(`/games/${gameId}/designer/science`)}
|
|
>
|
|
{i18n.t("game.view.designer.science")}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.bottom-tabs {
|
|
display: none;
|
|
position: relative;
|
|
}
|
|
@media (max-width: 767.98px) {
|
|
.bottom-tabs {
|
|
display: block;
|
|
}
|
|
}
|
|
.tabs {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
align-items: stretch;
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
border-top: 1px solid var(--color-border-subtle);
|
|
font-family: var(--font-sans);
|
|
}
|
|
.tabs button {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.15rem;
|
|
padding: 0.4rem 0.25rem;
|
|
font: inherit;
|
|
font-size: 0.75rem;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.tabs button.active {
|
|
color: var(--color-text);
|
|
background: var(--color-surface-hover);
|
|
}
|
|
.tabs .icon {
|
|
font-size: 1.25rem;
|
|
}
|
|
.drawer {
|
|
position: fixed;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 3.25rem;
|
|
max-height: calc(100vh - 6rem);
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--color-surface-overlay);
|
|
color: var(--color-text);
|
|
border-top: 1px solid var(--color-border);
|
|
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
|
z-index: 50;
|
|
font-family: var(--font-sans);
|
|
}
|
|
.drawer > button,
|
|
.drawer > details > summary {
|
|
text-align: left;
|
|
font: inherit;
|
|
padding: 0.55rem 0.9rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.drawer > button:hover,
|
|
.drawer > details > summary:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
.drawer > details > summary {
|
|
list-style: none;
|
|
}
|
|
.drawer > details > summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.drawer > details > summary::after {
|
|
content: "▸";
|
|
float: right;
|
|
opacity: 0.7;
|
|
}
|
|
.drawer > 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.4rem 0.5rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.sub > button:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
</style>
|