e82c9f8bbd
Clicking the current-turn row in the header turn navigator while already viewing it routed through returnToCurrent() → viewTurn(currentTurn), which re-fetches the live report and flips the view through `loading`. At turn 0 the only row is the live turn, so the dropdown always fired a pointless backend round-trip and redraw. Guard goToTurn() against re-selecting the on-screen turn (turn === viewedTurn): just close the popover and stop. Leaving history is unaffected — there the viewed turn differs from the target. Closes #45 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
280 lines
7.4 KiB
Svelte
280 lines
7.4 KiB
Svelte
<!--
|
|
Phase 26 header turn navigator. Replaces the Phase 11 inline turn
|
|
number with a `← Turn N →` triplet. The arrows step ±1 (disabled at
|
|
boundaries `0` and `currentTurn`), the middle button opens a popover
|
|
listing every turn `Turn #0`…`Turn #currentTurn` with the current row
|
|
tagged. No free-text input — every reachable turn is in the list, so
|
|
there is nothing to validate.
|
|
|
|
Desktop and mobile share the same component: an absolute-positioned
|
|
popover anchored to the trigger on desktop becomes a fixed full-width
|
|
drawer below the 768 px breakpoint, mirroring `view-menu.svelte`.
|
|
|
|
Selecting a row calls `gameState.viewTurn(N)`; the row that matches
|
|
`currentTurn` delegates to `gameState.returnToCurrent()` so the
|
|
"leave history" path always flows through one method. Selecting the
|
|
row already on screen (`viewedTurn`) is a pure no-op — most visibly
|
|
at turn 0, where the sole row is the live turn — so the navigator
|
|
never re-fetches the report just to redraw the same snapshot.
|
|
-->
|
|
<script lang="ts">
|
|
import { getContext, onMount } from "svelte";
|
|
import { i18n } from "$lib/i18n/index.svelte";
|
|
import { restoreFocus } from "$lib/a11y/restore-focus";
|
|
import {
|
|
GAME_STATE_CONTEXT_KEY,
|
|
type GameStateStore,
|
|
} from "$lib/game-state.svelte";
|
|
|
|
const gameState = getContext<GameStateStore | undefined>(
|
|
GAME_STATE_CONTEXT_KEY,
|
|
);
|
|
|
|
let open = $state(false);
|
|
let rootEl: HTMLDivElement | null = $state(null);
|
|
|
|
const currentTurn = $derived(gameState?.currentTurn ?? 0);
|
|
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
|
|
const ready = $derived(gameState?.status === "ready");
|
|
const canPrev = $derived(ready && viewedTurn > 0);
|
|
const canNext = $derived(ready && viewedTurn < currentTurn);
|
|
// Until the boot completes the store has no report, so the
|
|
// counter falls back to the same `?` placeholder Phase 11 used in
|
|
// the static headline. Avoids briefly flashing `turn 0` while the
|
|
// lobby / report calls are in flight.
|
|
const turnText = $derived(
|
|
ready ? String(viewedTurn) : i18n.t("game.shell.unknown"),
|
|
);
|
|
|
|
// Descending list newest → oldest. The popover stays compact for
|
|
// short games and scrolls for long ones; the current-turn row is
|
|
// pinned at the top with an explicit badge so the affordance to
|
|
// jump back is always reachable without scrolling.
|
|
const turns = $derived.by(() => {
|
|
if (!ready) return [] as number[];
|
|
const out: number[] = [];
|
|
for (let i = currentTurn; i >= 0; i--) {
|
|
out.push(i);
|
|
}
|
|
return out;
|
|
});
|
|
|
|
function toggleOpen(): void {
|
|
if (!ready) return;
|
|
open = !open;
|
|
}
|
|
|
|
async function goToTurn(turn: number): Promise<void> {
|
|
open = false;
|
|
if (gameState === undefined) return;
|
|
// Re-selecting the turn already on screen changes nothing, so just
|
|
// close the popover. Without this guard the current-turn row routes
|
|
// through `returnToCurrent()` → `viewTurn(currentTurn)`, which
|
|
// re-fetches the live report and flips the view through `loading` —
|
|
// most visibly at turn 0, where the only row is the live turn.
|
|
// Leaving history still works: there the viewed turn differs.
|
|
if (turn === gameState.viewedTurn) return;
|
|
if (turn === gameState.currentTurn) {
|
|
await gameState.returnToCurrent();
|
|
return;
|
|
}
|
|
await gameState.viewTurn(turn);
|
|
}
|
|
|
|
async function step(delta: number): Promise<void> {
|
|
if (gameState === undefined) return;
|
|
const next = viewedTurn + delta;
|
|
if (next < 0 || next > currentTurn) return;
|
|
if (next === gameState.currentTurn) {
|
|
await gameState.returnToCurrent();
|
|
return;
|
|
}
|
|
await gameState.viewTurn(next);
|
|
}
|
|
|
|
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="turn-navigator" bind:this={rootEl} data-testid="turn-navigator">
|
|
<button
|
|
type="button"
|
|
class="step"
|
|
data-testid="turn-navigator-prev"
|
|
aria-label={i18n.t("game.shell.turn.prev")}
|
|
disabled={!canPrev}
|
|
onclick={() => void step(-1)}
|
|
>
|
|
←
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="trigger"
|
|
data-testid="turn-navigator-trigger"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
aria-label={open
|
|
? i18n.t("game.shell.turn.close_navigator")
|
|
: i18n.t("game.shell.turn.open_navigator")}
|
|
disabled={!ready}
|
|
onclick={toggleOpen}
|
|
>
|
|
{i18n.t("game.shell.turn.label", { turn: turnText })}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="step"
|
|
data-testid="turn-navigator-next"
|
|
aria-label={i18n.t("game.shell.turn.next")}
|
|
disabled={!canNext}
|
|
onclick={() => void step(1)}
|
|
>
|
|
→
|
|
</button>
|
|
{#if open}
|
|
<div
|
|
class="surface"
|
|
role="menu"
|
|
data-testid="turn-navigator-list"
|
|
use:restoreFocus
|
|
>
|
|
{#each turns as turn (turn)}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
class="row"
|
|
class:viewed={turn === viewedTurn}
|
|
data-testid={`turn-navigator-item-${turn}`}
|
|
onclick={() => void goToTurn(turn)}
|
|
>
|
|
<span class="label">
|
|
{i18n.t("game.shell.turn.list_item", { turn: String(turn) })}
|
|
</span>
|
|
{#if turn === currentTurn}
|
|
<span class="badge" data-testid="turn-navigator-current-badge">
|
|
{i18n.t("game.shell.history.current_badge")}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.turn-navigator {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: stretch;
|
|
gap: 0.25rem;
|
|
}
|
|
.step,
|
|
.trigger {
|
|
font: inherit;
|
|
font-size: 1rem;
|
|
padding: 0.25rem 0.55rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
line-height: 1;
|
|
}
|
|
.step:hover:not(:disabled),
|
|
.trigger:hover:not(:disabled) {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
.step:disabled,
|
|
.trigger:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.trigger {
|
|
min-width: 5rem;
|
|
font-weight: 500;
|
|
}
|
|
.surface {
|
|
position: absolute;
|
|
top: calc(100% + 0.25rem);
|
|
left: 0;
|
|
min-width: 10rem;
|
|
max-height: 18rem;
|
|
overflow-y: auto;
|
|
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;
|
|
}
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
text-align: left;
|
|
font: inherit;
|
|
padding: 0.4rem 0.75rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.row:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
.row.viewed {
|
|
font-weight: 600;
|
|
background: var(--color-surface-raised);
|
|
}
|
|
.label {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.badge {
|
|
flex: 0 0 auto;
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.05rem 0.4rem;
|
|
background: var(--color-border);
|
|
color: var(--color-text);
|
|
border-radius: 999px;
|
|
}
|
|
@media (max-width: 767.98px) {
|
|
.surface {
|
|
position: fixed;
|
|
top: 3rem;
|
|
right: 0;
|
|
left: 0;
|
|
min-width: 0;
|
|
max-height: calc(100vh - 3rem);
|
|
border-radius: 0;
|
|
border-left: 0;
|
|
border-right: 0;
|
|
}
|
|
}
|
|
</style>
|