fix(ui): F8-10 owner-feedback — persistent filters, camera, disabled visual, dropdown narrowing (#53)
Polish pass after the first F8-10 walkthrough:
- table-planets: moved the `foreign` chip to the end of the row and
hid the race dropdown until `foreign` is on (it never made sense
to pick a race while the bucket itself was off).
- persistent per-table filter / sort state — extracted to
`table-{planets,ship-groups,fleets}-state.svelte.ts` singletons so
a row click → map → back to the table restores the prior chip /
dropdown / sort state. Held in memory only; an F5 still resets.
- table-ship-groups: the planet and class dropdowns now narrow to
the slice surviving the owner checkboxes, so toggling `foreign`
off removes planets / classes touched only by foreign rows.
- map.svelte: camera (centre + zoom) is captured on every dispose
path into a new `GameStateStore.lastCamera` and consumed on the
next mount, so leaving the map for any other active view and
coming back restores the prior pan / zoom. A pending focus from
the tables still wins for the centre point.
- table-ship-classes: `:disabled` now reads as disabled (muted
colour, no hover ring, not-allowed cursor) — the click was already
a no-op, only the visual was lying.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -467,15 +467,23 @@ preference the store already manages.
|
||||
if (canvasEl === null || containerEl === null) return;
|
||||
// Capture camera state before disposing so a remount inside
|
||||
// the same game (e.g. cargo-route overlay change) keeps the
|
||||
// user's pan/zoom. A new game / first mount has no prior
|
||||
// camera, so `previousCamera` stays null and the default
|
||||
// centring path runs.
|
||||
// user's pan / zoom. On a cold mount with no live `handle` we
|
||||
// fall back to the per-game `store.lastCamera` snapshot, so
|
||||
// leaving the map for a table / report and coming back also
|
||||
// restores the prior view. A new game / first mount has no
|
||||
// prior camera in either source, so `previousCamera` stays
|
||||
// null and the default centring path runs.
|
||||
const previousGameId = mountedGameId;
|
||||
const targetGameId = store?.gameId ?? "";
|
||||
const previousCamera =
|
||||
handle !== null && previousGameId === targetGameId
|
||||
? handle.getCamera()
|
||||
: null;
|
||||
let previousCamera: ReturnType<RendererHandle["getCamera"]> | null = null;
|
||||
if (handle !== null && previousGameId === targetGameId) {
|
||||
previousCamera = handle.getCamera();
|
||||
} else if (handle === null && store?.lastCamera) {
|
||||
previousCamera = store.lastCamera;
|
||||
}
|
||||
if (handle !== null && store !== undefined) {
|
||||
store.lastCamera = handle.getCamera();
|
||||
}
|
||||
if (detachClick !== null) {
|
||||
detachClick();
|
||||
detachClick = null;
|
||||
@@ -809,6 +817,9 @@ preference the store already manages.
|
||||
detachDebugSurface = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
// Persist the camera snapshot to the per-game store so the
|
||||
// next mount (active-view switch back to map) restores it.
|
||||
if (store !== undefined) store.lastCamera = handle.getCamera();
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// F8-10 fleets table — module-level filter / sort rune.
|
||||
// Mirrors `table-planets-state.svelte.ts`. See that file for the
|
||||
// rationale.
|
||||
|
||||
export type FleetsSortColumn = "name" | "groupCount" | "state" | "location" | "speed";
|
||||
export type FleetsSortDirection = "asc" | "desc";
|
||||
|
||||
export interface FleetsTableState {
|
||||
sortColumn: FleetsSortColumn;
|
||||
sortDirection: FleetsSortDirection;
|
||||
planetFilter: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: FleetsTableState = {
|
||||
sortColumn: "name",
|
||||
sortDirection: "asc",
|
||||
planetFilter: "",
|
||||
};
|
||||
|
||||
export const fleetsTableState: FleetsTableState = $state({ ...DEFAULT_STATE });
|
||||
|
||||
export function resetFleetsTableState(): void {
|
||||
Object.assign(fleetsTableState, DEFAULT_STATE);
|
||||
}
|
||||
@@ -30,14 +30,10 @@ Click semantics:
|
||||
} from "$lib/selection.svelte";
|
||||
import ViewState from "$lib/ui/view-state.svelte";
|
||||
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||
|
||||
type SortColumn =
|
||||
| "name"
|
||||
| "groupCount"
|
||||
| "state"
|
||||
| "location"
|
||||
| "speed";
|
||||
type SortDirection = "asc" | "desc";
|
||||
import {
|
||||
fleetsTableState as persistent,
|
||||
type FleetsSortColumn as SortColumn,
|
||||
} from "./table-fleets-state.svelte";
|
||||
|
||||
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||
name: "game.table.fleets.column.name",
|
||||
@@ -60,9 +56,8 @@ Click semantics:
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||
|
||||
let sortColumn: SortColumn = $state("name");
|
||||
let sortDirection: SortDirection = $state("asc");
|
||||
let planetFilter: string = $state("");
|
||||
// `persistent` (module-level rune above) drives the dropdown
|
||||
// selection so the user's planet filter survives navigation.
|
||||
|
||||
const reportLoaded = $derived(
|
||||
rendered?.report !== null && rendered?.report !== undefined,
|
||||
@@ -85,7 +80,8 @@ Click semantics:
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const planet = planetFilter === "" ? null : Number(planetFilter);
|
||||
const planet =
|
||||
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
|
||||
return fleets.filter((f) => {
|
||||
if (planet === null) return true;
|
||||
return f.destination === planet || f.origin === planet;
|
||||
@@ -94,8 +90,8 @@ Click semantics:
|
||||
|
||||
const sorted = $derived.by(() => {
|
||||
const list = [...filtered];
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
list.sort((a, b) => compare(a, b, sortColumn) * dir);
|
||||
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -123,17 +119,17 @@ Click semantics:
|
||||
}
|
||||
|
||||
function toggleSort(column: SortColumn): void {
|
||||
if (sortColumn === column) {
|
||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
if (persistent.sortColumn === column) {
|
||||
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
sortColumn = column;
|
||||
sortDirection = "asc";
|
||||
persistent.sortColumn = column;
|
||||
persistent.sortDirection = "asc";
|
||||
}
|
||||
|
||||
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||
if (sortColumn !== column) return "none";
|
||||
return sortDirection === "asc" ? "ascending" : "descending";
|
||||
if (persistent.sortColumn !== column) return "none";
|
||||
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||
}
|
||||
|
||||
function isInSpace(f: ReportLocalFleet): boolean {
|
||||
@@ -189,7 +185,7 @@ Click semantics:
|
||||
<span>{i18n.t("game.table.fleets.filter.planet")}</span>
|
||||
<select
|
||||
data-testid="fleets-filter-planet"
|
||||
bind:value={planetFilter}
|
||||
bind:value={persistent.planetFilter}
|
||||
>
|
||||
<option value=""
|
||||
>{i18n.t("game.table.fleets.filter.planet.all")}</option
|
||||
@@ -233,9 +229,9 @@ Click semantics:
|
||||
onclick={() => toggleSort(column)}
|
||||
>
|
||||
{i18n.t(COLUMN_LABELS[column])}
|
||||
{#if sortColumn === column}
|
||||
{#if persistent.sortColumn === column}
|
||||
<span class="sort-indicator" aria-hidden="true">
|
||||
{sortDirection === "asc" ? "▲" : "▼"}
|
||||
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// F8-10 planets table — module-level filter / sort rune.
|
||||
//
|
||||
// Held outside the component so the user's filter selections survive
|
||||
// the component being unmounted (e.g. row click → map → back to the
|
||||
// table). Held in memory only; an F5 reloads the report and the
|
||||
// defaults take over.
|
||||
|
||||
export type PlanetSortColumn =
|
||||
| "number"
|
||||
| "name"
|
||||
| "kind"
|
||||
| "owner"
|
||||
| "size"
|
||||
| "resources";
|
||||
|
||||
export type PlanetSortDirection = "asc" | "desc";
|
||||
|
||||
export interface PlanetsTableState {
|
||||
sortColumn: PlanetSortColumn;
|
||||
sortDirection: PlanetSortDirection;
|
||||
showLocal: boolean;
|
||||
showOther: boolean;
|
||||
showUninhabited: boolean;
|
||||
showUnknown: boolean;
|
||||
ownerFilter: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: PlanetsTableState = {
|
||||
sortColumn: "number",
|
||||
sortDirection: "asc",
|
||||
showLocal: true,
|
||||
showOther: true,
|
||||
showUninhabited: true,
|
||||
showUnknown: true,
|
||||
ownerFilter: "",
|
||||
};
|
||||
|
||||
export const planetsTableState: PlanetsTableState = $state({ ...DEFAULT_STATE });
|
||||
|
||||
/**
|
||||
* resetPlanetsTableState restores every field to its default value.
|
||||
* Production code never calls it; the Vitest harness uses it from
|
||||
* `beforeEach` to keep cases independent (the rune is a module-level
|
||||
* singleton that otherwise carries state across test boundaries).
|
||||
*/
|
||||
export function resetPlanetsTableState(): void {
|
||||
Object.assign(planetsTableState, DEFAULT_STATE);
|
||||
}
|
||||
@@ -25,15 +25,11 @@ fetching here.
|
||||
} from "$lib/selection.svelte";
|
||||
import ViewState from "$lib/ui/view-state.svelte";
|
||||
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||
import {
|
||||
planetsTableState as persistent,
|
||||
type PlanetSortColumn as SortColumn,
|
||||
} from "./table-planets-state.svelte";
|
||||
|
||||
type SortColumn =
|
||||
| "number"
|
||||
| "name"
|
||||
| "kind"
|
||||
| "owner"
|
||||
| "size"
|
||||
| "resources";
|
||||
type SortDirection = "asc" | "desc";
|
||||
type PlanetKind = ReportPlanet["kind"];
|
||||
|
||||
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||
@@ -73,19 +69,13 @@ fetching here.
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||
|
||||
let sortColumn: SortColumn = $state("number");
|
||||
let sortDirection: SortDirection = $state("asc");
|
||||
|
||||
// Kind toggles default to all-on; owner narrows the `other` slice
|
||||
// only — "" means "all owners". The dropdown is rendered as soon
|
||||
// as at least one foreign planet exists in the report, regardless
|
||||
// of the foreign toggle, so the user can re-enable foreign and
|
||||
// keep their owner pick.
|
||||
let showLocal = $state(true);
|
||||
let showOther = $state(true);
|
||||
let showUninhabited = $state(true);
|
||||
let showUnknown = $state(true);
|
||||
let ownerFilter: string = $state("");
|
||||
// `persistent` (declared in the module script above) is the live
|
||||
// source for every filter / sort selection so leaving the table
|
||||
// and coming back restores the prior state. Kind toggles default
|
||||
// to all-on; owner narrows the `other` slice only — "" means
|
||||
// "all owners". The owner dropdown is conditional on
|
||||
// `persistent.showOther`, so toggling foreign off hides the
|
||||
// chooser; the stored owner value is preserved across that flip.
|
||||
|
||||
const planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
|
||||
const reportLoaded = $derived(
|
||||
@@ -104,12 +94,12 @@ fetching here.
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
return planets.filter((p) => {
|
||||
if (p.kind === "local" && !showLocal) return false;
|
||||
if (p.kind === "uninhabited" && !showUninhabited) return false;
|
||||
if (p.kind === "unidentified" && !showUnknown) return false;
|
||||
if (p.kind === "local" && !persistent.showLocal) return false;
|
||||
if (p.kind === "uninhabited" && !persistent.showUninhabited) return false;
|
||||
if (p.kind === "unidentified" && !persistent.showUnknown) return false;
|
||||
if (p.kind === "other") {
|
||||
if (!showOther) return false;
|
||||
if (ownerFilter !== "" && p.owner !== ownerFilter) return false;
|
||||
if (!persistent.showOther) return false;
|
||||
if (persistent.ownerFilter !== "" && p.owner !== persistent.ownerFilter) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -117,8 +107,8 @@ fetching here.
|
||||
|
||||
const sorted = $derived.by(() => {
|
||||
const list = [...filtered];
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
list.sort((a, b) => compare(a, b, sortColumn) * dir);
|
||||
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -143,17 +133,17 @@ fetching here.
|
||||
}
|
||||
|
||||
function toggleSort(column: SortColumn): void {
|
||||
if (sortColumn === column) {
|
||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
if (persistent.sortColumn === column) {
|
||||
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
sortColumn = column;
|
||||
sortDirection = "asc";
|
||||
persistent.sortColumn = column;
|
||||
persistent.sortDirection = "asc";
|
||||
}
|
||||
|
||||
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||
if (sortColumn !== column) return "none";
|
||||
return sortDirection === "asc" ? "ascending" : "descending";
|
||||
if (persistent.sortColumn !== column) return "none";
|
||||
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||
}
|
||||
|
||||
function openOnMap(planet: ReportPlanet): void {
|
||||
@@ -179,23 +169,15 @@ fetching here.
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="planets-filter-own"
|
||||
bind:checked={showLocal}
|
||||
bind:checked={persistent.showLocal}
|
||||
/>
|
||||
<span>{i18n.t("game.table.planets.kind.own")}</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="planets-filter-foreign"
|
||||
bind:checked={showOther}
|
||||
/>
|
||||
<span>{i18n.t("game.table.planets.kind.foreign")}</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="planets-filter-uninhabited"
|
||||
bind:checked={showUninhabited}
|
||||
bind:checked={persistent.showUninhabited}
|
||||
/>
|
||||
<span>{i18n.t("game.table.planets.kind.uninhabited")}</span>
|
||||
</label>
|
||||
@@ -203,16 +185,24 @@ fetching here.
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="planets-filter-unknown"
|
||||
bind:checked={showUnknown}
|
||||
bind:checked={persistent.showUnknown}
|
||||
/>
|
||||
<span>{i18n.t("game.table.planets.kind.unknown")}</span>
|
||||
</label>
|
||||
{#if owners.length > 0}
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="planets-filter-foreign"
|
||||
bind:checked={persistent.showOther}
|
||||
/>
|
||||
<span>{i18n.t("game.table.planets.kind.foreign")}</span>
|
||||
</label>
|
||||
{#if persistent.showOther && owners.length > 0}
|
||||
<label class="owner">
|
||||
<span>{i18n.t("game.table.planets.filter.owner")}</span>
|
||||
<select
|
||||
data-testid="planets-filter-owner"
|
||||
bind:value={ownerFilter}
|
||||
bind:value={persistent.ownerFilter}
|
||||
>
|
||||
<option value=""
|
||||
>{i18n.t("game.table.planets.filter.owner.all")}</option
|
||||
@@ -256,9 +246,9 @@ fetching here.
|
||||
onclick={() => toggleSort(column)}
|
||||
>
|
||||
{i18n.t(COLUMN_LABELS[column])}
|
||||
{#if sortColumn === column}
|
||||
{#if persistent.sortColumn === column}
|
||||
<span class="sort-indicator" aria-hidden="true">
|
||||
{sortDirection === "asc" ? "▲" : "▼"}
|
||||
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -359,7 +359,12 @@ data fetching is performed here — the layout is responsible.
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete:hover {
|
||||
.delete:not(:disabled):hover {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.delete:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// F8-10 ship-groups table — module-level filter / sort rune.
|
||||
// Mirrors `table-planets-state.svelte.ts`. See that file for the
|
||||
// rationale.
|
||||
|
||||
export type ShipGroupsSortColumn =
|
||||
| "owner"
|
||||
| "class"
|
||||
| "count"
|
||||
| "race"
|
||||
| "location"
|
||||
| "mass"
|
||||
| "speed";
|
||||
|
||||
export type ShipGroupsSortDirection = "asc" | "desc";
|
||||
|
||||
export interface ShipGroupsTableState {
|
||||
sortColumn: ShipGroupsSortColumn;
|
||||
sortDirection: ShipGroupsSortDirection;
|
||||
showOwn: boolean;
|
||||
showForeign: boolean;
|
||||
planetFilter: string;
|
||||
classFilter: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: ShipGroupsTableState = {
|
||||
sortColumn: "owner",
|
||||
sortDirection: "asc",
|
||||
showOwn: true,
|
||||
showForeign: true,
|
||||
planetFilter: "",
|
||||
classFilter: "",
|
||||
};
|
||||
|
||||
export const shipGroupsTableState: ShipGroupsTableState = $state({
|
||||
...DEFAULT_STATE,
|
||||
});
|
||||
|
||||
export function resetShipGroupsTableState(): void {
|
||||
Object.assign(shipGroupsTableState, DEFAULT_STATE);
|
||||
}
|
||||
@@ -38,16 +38,11 @@ categories.
|
||||
} from "$lib/selection.svelte";
|
||||
import ViewState from "$lib/ui/view-state.svelte";
|
||||
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||
import {
|
||||
shipGroupsTableState as persistent,
|
||||
type ShipGroupsSortColumn as SortColumn,
|
||||
} from "./table-ship-groups-state.svelte";
|
||||
|
||||
type SortColumn =
|
||||
| "owner"
|
||||
| "class"
|
||||
| "count"
|
||||
| "race"
|
||||
| "location"
|
||||
| "mass"
|
||||
| "speed";
|
||||
type SortDirection = "asc" | "desc";
|
||||
type OwnerKind = "own" | "foreign";
|
||||
|
||||
type Row = {
|
||||
@@ -90,12 +85,9 @@ categories.
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||
|
||||
let sortColumn: SortColumn = $state("owner");
|
||||
let sortDirection: SortDirection = $state("asc");
|
||||
let showOwn = $state(true);
|
||||
let showForeign = $state(true);
|
||||
let planetFilter: string = $state("");
|
||||
let classFilter: string = $state("");
|
||||
// `persistent` (module-level rune above) drives every filter /
|
||||
// sort selection so the user's choices survive table↔map round
|
||||
// trips.
|
||||
|
||||
const reportLoaded = $derived(
|
||||
rendered?.report !== null && rendered?.report !== undefined,
|
||||
@@ -145,15 +137,27 @@ categories.
|
||||
return acc;
|
||||
});
|
||||
|
||||
// rowsByOwner is the slice surviving just the owner checkboxes,
|
||||
// reused for the planet / class dropdown options so toggling
|
||||
// "foreign" off prunes the choices to only own-group planets and
|
||||
// classes (and vice versa).
|
||||
const rowsByOwner = $derived.by(() => {
|
||||
return rows.filter((r) => {
|
||||
if (r.owner === "own" && !persistent.showOwn) return false;
|
||||
if (r.owner === "foreign" && !persistent.showForeign) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const classes = $derived.by(() => {
|
||||
const set = new Set<string>();
|
||||
for (const r of rows) if (r.class !== "") set.add(r.class);
|
||||
for (const r of rowsByOwner) if (r.class !== "") set.add(r.class);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
const planets = $derived.by(() => {
|
||||
const set = new Set<number>();
|
||||
for (const r of rows) {
|
||||
for (const r of rowsByOwner) {
|
||||
set.add(r.destination);
|
||||
if (r.origin !== null) set.add(r.origin);
|
||||
}
|
||||
@@ -161,22 +165,23 @@ categories.
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const planet = planetFilter === "" ? null : Number(planetFilter);
|
||||
return rows.filter((r) => {
|
||||
if (r.owner === "own" && !showOwn) return false;
|
||||
if (r.owner === "foreign" && !showForeign) return false;
|
||||
const planet =
|
||||
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
|
||||
return rowsByOwner.filter((r) => {
|
||||
if (planet !== null && r.destination !== planet && r.origin !== planet) {
|
||||
return false;
|
||||
}
|
||||
if (classFilter !== "" && r.class !== classFilter) return false;
|
||||
if (persistent.classFilter !== "" && r.class !== persistent.classFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const sorted = $derived.by(() => {
|
||||
const list = [...filtered];
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
list.sort((a, b) => compare(a, b, sortColumn) * dir);
|
||||
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -204,17 +209,17 @@ categories.
|
||||
}
|
||||
|
||||
function toggleSort(column: SortColumn): void {
|
||||
if (sortColumn === column) {
|
||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
if (persistent.sortColumn === column) {
|
||||
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
sortColumn = column;
|
||||
sortDirection = "asc";
|
||||
persistent.sortColumn = column;
|
||||
persistent.sortDirection = "asc";
|
||||
}
|
||||
|
||||
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||
if (sortColumn !== column) return "none";
|
||||
return sortDirection === "asc" ? "ascending" : "descending";
|
||||
if (persistent.sortColumn !== column) return "none";
|
||||
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||
}
|
||||
|
||||
function isInSpace(row: Row): boolean {
|
||||
@@ -263,7 +268,7 @@ categories.
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="ship-groups-filter-own"
|
||||
bind:checked={showOwn}
|
||||
bind:checked={persistent.showOwn}
|
||||
/>
|
||||
<span>{i18n.t("game.table.ship_groups.owner.own")}</span>
|
||||
</label>
|
||||
@@ -271,7 +276,7 @@ categories.
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="ship-groups-filter-foreign"
|
||||
bind:checked={showForeign}
|
||||
bind:checked={persistent.showForeign}
|
||||
/>
|
||||
<span>{i18n.t("game.table.ship_groups.owner.foreign")}</span>
|
||||
</label>
|
||||
@@ -280,7 +285,7 @@ categories.
|
||||
<span>{i18n.t("game.table.ship_groups.filter.planet")}</span>
|
||||
<select
|
||||
data-testid="ship-groups-filter-planet"
|
||||
bind:value={planetFilter}
|
||||
bind:value={persistent.planetFilter}
|
||||
>
|
||||
<option value=""
|
||||
>{i18n.t("game.table.ship_groups.filter.planet.all")}</option
|
||||
@@ -298,7 +303,7 @@ categories.
|
||||
<span>{i18n.t("game.table.ship_groups.filter.class")}</span>
|
||||
<select
|
||||
data-testid="ship-groups-filter-class"
|
||||
bind:value={classFilter}
|
||||
bind:value={persistent.classFilter}
|
||||
>
|
||||
<option value=""
|
||||
>{i18n.t("game.table.ship_groups.filter.class.all")}</option
|
||||
@@ -342,9 +347,9 @@ categories.
|
||||
onclick={() => toggleSort(column)}
|
||||
>
|
||||
{i18n.t(COLUMN_LABELS[column])}
|
||||
{#if sortColumn === column}
|
||||
{#if persistent.sortColumn === column}
|
||||
<span class="sort-indicator" aria-hidden="true">
|
||||
{sortDirection === "asc" ? "▲" : "▼"}
|
||||
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -117,6 +117,17 @@ export class GameStateStore {
|
||||
* `RendererHandle.setHiddenPrimitiveIds`.
|
||||
*/
|
||||
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
||||
/**
|
||||
* lastCamera is the most recent map camera snapshot (centre + zoom)
|
||||
* captured before the renderer was disposed — either because the
|
||||
* map view unmounted (active-view switch) or because it remounted
|
||||
* inside the same session. `map.svelte` reads it on cold mount so
|
||||
* leaving the map for a table / report and coming back keeps the
|
||||
* user's prior pan / zoom. Resetting to null falls back to the
|
||||
* default world-centre + minScale fit. Held in memory only; an F5
|
||||
* re-loads the report and the default fit takes over.
|
||||
*/
|
||||
lastCamera: { centerX: number; centerY: number; scale: number } | null = $state(null);
|
||||
error: string | null = $state(null);
|
||||
/**
|
||||
* notFound is the distinct "this game is not in the player's list"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectionStore,
|
||||
} from "../src/lib/selection.svelte";
|
||||
import { activeView } from "../src/lib/app-nav.svelte";
|
||||
import { resetFleetsTableState } from "../src/lib/active-view/table-fleets-state.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
const pageMock = vi.hoisted(() => ({
|
||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
||||
pageMock.params = { id: "g1" };
|
||||
gotoMock.mockClear();
|
||||
activeView.reset();
|
||||
resetFleetsTableState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectionStore,
|
||||
} from "../src/lib/selection.svelte";
|
||||
import { activeView } from "../src/lib/app-nav.svelte";
|
||||
import { resetPlanetsTableState } from "../src/lib/active-view/table-planets-state.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
const pageMock = vi.hoisted(() => ({
|
||||
@@ -45,6 +46,7 @@ beforeEach(() => {
|
||||
pageMock.params = { id: "g1" };
|
||||
gotoMock.mockClear();
|
||||
activeView.reset();
|
||||
resetPlanetsTableState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -189,6 +191,25 @@ describe("planets table", () => {
|
||||
expect(activeView.view).toBe("map");
|
||||
});
|
||||
|
||||
test("filter checkboxes survive an unmount/remount cycle", async () => {
|
||||
const report = makeReport([
|
||||
planet({ number: 1, kind: "local" }),
|
||||
planet({ number: 2, kind: "other", owner: "Klingon" }),
|
||||
]);
|
||||
const first = mount(report);
|
||||
await fireEvent.click(first.getByTestId("planets-filter-own"));
|
||||
first.unmount();
|
||||
const second = mount(report);
|
||||
const ownCheckbox = second.getByTestId(
|
||||
"planets-filter-own",
|
||||
) as HTMLInputElement;
|
||||
expect(ownCheckbox.checked).toBe(false);
|
||||
const kinds = second
|
||||
.getAllByTestId("planets-row")
|
||||
.map((r) => r.getAttribute("data-kind"));
|
||||
expect(kinds).toEqual(["other"]);
|
||||
});
|
||||
|
||||
test("number column sorts ascending then descending", async () => {
|
||||
const ui = mount(
|
||||
makeReport([
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectionStore,
|
||||
} from "../src/lib/selection.svelte";
|
||||
import { activeView } from "../src/lib/app-nav.svelte";
|
||||
import { resetShipGroupsTableState } from "../src/lib/active-view/table-ship-groups-state.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
const pageMock = vi.hoisted(() => ({
|
||||
@@ -50,6 +51,7 @@ beforeEach(() => {
|
||||
pageMock.params = { id: "g1" };
|
||||
gotoMock.mockClear();
|
||||
activeView.reset();
|
||||
resetShipGroupsTableState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -287,6 +289,37 @@ describe("ship-groups table", () => {
|
||||
expect(activeView.view).toBe("map");
|
||||
});
|
||||
|
||||
test("planet/class dropdowns narrow to the owner-checkbox cut", async () => {
|
||||
const ui = mount(
|
||||
makeReport({
|
||||
planets: [planet(1), planet(2), planet(3)],
|
||||
local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })],
|
||||
other: [
|
||||
otherGroup({ class: "Hunter", destination: 3, race: "Klingon" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
// All four planet options available when both owner kinds are on
|
||||
const planetSelect = ui.getByTestId(
|
||||
"ship-groups-filter-planet",
|
||||
) as HTMLSelectElement;
|
||||
const valuesFull = Array.from(planetSelect.options).map((o) => o.value);
|
||||
expect(valuesFull).toContain("1");
|
||||
expect(valuesFull).toContain("3");
|
||||
// Hide foreign rows; planet 3 (only touched by Klingon) disappears
|
||||
await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign"));
|
||||
const valuesNarrowed = Array.from(planetSelect.options).map((o) => o.value);
|
||||
expect(valuesNarrowed).toContain("1");
|
||||
expect(valuesNarrowed).not.toContain("3");
|
||||
// Class dropdown narrows too: Hunter disappears
|
||||
const classSelect = ui.getByTestId(
|
||||
"ship-groups-filter-class",
|
||||
) as HTMLSelectElement;
|
||||
const classValues = Array.from(classSelect.options).map((o) => o.value);
|
||||
expect(classValues).not.toContain("Hunter");
|
||||
expect(classValues).toContain("Cruiser");
|
||||
});
|
||||
|
||||
test("click on in-space foreign group focuses other variant by index", async () => {
|
||||
const ui = mount(
|
||||
makeReport({
|
||||
|
||||
Reference in New Issue
Block a user