fix(ui): F8-06 calculator polish — unified spinner UX, lock-infeasible on (0, 1), dropdown reset-changes
Owner review on PR #61: - п.9 (option B). Hide the native spinner on EVERY numeric input in the calculator (DWSC blocks, armament, tech, planet MAT, custom load, lock value, modernization target tech) and drive every step through ArrowUp / ArrowDown. The column widths stay stable and the inputs read consistently across the whole row. The ship blocks keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps ±1 with a JS handler instead of relying on the native spinner. Other inputs step by their natural grain (±0.001 for tech / lock, ±0.01 for MAT / load). - п.10. Tech-level labels (`tech-val`) and the planet MAT label (`mat-val`) now read through the same `Ceil3` formatter as the derived results, so plain-text numeric values share the report's 3-decimal tabular formatting. The design-area component receives `formatNumber` as a prop; the resolved (goal-seek) cell uses the same formatter, so the read-only computed value matches the rest of the row. - п.12. `computeCalculator` now validates the back-solved block against the same DWSC rule the live validator enforces (`0` or `≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack 0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged infeasible — the lock input flips red and the claimed block is NOT back-solved into the invalid range, so the design preview keeps reading the user's own typed values instead of silently showing a sub-1 block. - new. Selecting an existing ship class from the name datalist now loads it immediately. `change` fires only on blur in Firefox, which is why the previous behaviour looked delayed; switching the load to `oninput` with an `InputEvent.inputType` check makes the load synchronous everywhere (datalist replacement carries `"insertReplacementText"` in Chromium / WebKit, `undefined` in Firefox; keyboard typing always carries a typing `inputType`). Before loading we compare the live blocks to the previously loaded class (or to the empty defaults) and, if they differ, ask through a `window.confirm`. On decline we revert the name field and leave the design untouched. Tests: calculator-tab and calc-model gain six cases (armament step, tech/MAT formatter labels, lock infeasible on (0, 1) for both attack→weapons and emptyMass→cargo, lock-value Arrow step, dropdown immediate load + confirm-blocks-load + confirm-allows-load), all 779 vitest tests green. docs/calculator-ux.md follows the new behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,18 @@ export interface CalculatorResult {
|
||||
outputs: CalculatorOutputs | null;
|
||||
}
|
||||
|
||||
// isClaimedBlockValid checks that a solver result, before we apply it
|
||||
// to the resolved blocks, satisfies the same per-field rules the live
|
||||
// validator enforces on user-typed values (`pkg/calc/validator.go` /
|
||||
// `lib/util/ship-class-validation`). The four claimable blocks all
|
||||
// share the DWSC rule, so a single predicate suffices. Used to flag
|
||||
// a goal-seek target as infeasible when the only block that would
|
||||
// reach it falls in the (0, 1) gap.
|
||||
function isClaimedBlockValid(solved: number): boolean {
|
||||
if (!Number.isFinite(solved)) return false;
|
||||
return solved === 0 || solved >= 1;
|
||||
}
|
||||
|
||||
function resolveLoad(
|
||||
mode: LoadMode,
|
||||
customLoad: number,
|
||||
@@ -225,8 +237,18 @@ export function computeCalculator(
|
||||
if (solved === null) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
// The solver may produce a value that is mathematically
|
||||
// correct yet rejected by the ship-class value rules —
|
||||
// most commonly a DWSC block in the (0, 1) gap. Surface
|
||||
// that as an infeasible lock so the lock input flips
|
||||
// red and the outputs are suppressed, instead of
|
||||
// silently showing an invalid design.
|
||||
if (!isClaimedBlockValid(solved)) {
|
||||
lockFeasible = false;
|
||||
} else {
|
||||
blocks[claimed] = solved;
|
||||
computedInput = claimed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
techFloor: TechState;
|
||||
computedInput?: ClaimedInput | null;
|
||||
blocksReadonly?: boolean;
|
||||
// Formatter applied to the read-only tech value and to the
|
||||
// resolved (goal-seek) ship-block value. Same `fmt` as the
|
||||
// rest of the calculator, passed in so the design area stays
|
||||
// presentational and the parent owns the rounding policy.
|
||||
formatNumber: (value: number) => string;
|
||||
onTechInput: (key: TechKey) => void;
|
||||
onResetTech: (key: TechKey) => void;
|
||||
};
|
||||
@@ -58,6 +63,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
techFloor,
|
||||
computedInput = null,
|
||||
blocksReadonly = false,
|
||||
formatNumber,
|
||||
onTechInput,
|
||||
onResetTech,
|
||||
}: Props = $props();
|
||||
@@ -94,7 +100,9 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
|
||||
// native 0.01 step would produce invalid intermediates like 0.01.
|
||||
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
|
||||
// otherwise −0.1 down to 1, clamped at 0. Armament keeps native step 1.
|
||||
// otherwise −0.1 down to 1, clamped at 0. Armament uses a plain
|
||||
// integer step (±1, clamped at 0) so it follows the same
|
||||
// JS-driven idiom and we can hide the native spinner uniformly.
|
||||
function bumpBlock(value: number, dir: 1 | -1): number {
|
||||
if (dir === 1) {
|
||||
if (value < 1) return 1;
|
||||
@@ -103,31 +111,46 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
if (value <= 1) return 0;
|
||||
return Math.round((value - 0.1) * 10) / 10;
|
||||
}
|
||||
function bumpArmament(value: number, dir: 1 | -1): number {
|
||||
const next = Math.trunc(value) + dir;
|
||||
return next < 0 ? 0 : next;
|
||||
}
|
||||
function onBlockKey(
|
||||
event: KeyboardEvent,
|
||||
key: keyof DesignBlocksState,
|
||||
smart: boolean,
|
||||
): void {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
blocks[key] = bumpBlock(blocks[key], 1);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
blocks[key] = bumpBlock(blocks[key], -1);
|
||||
}
|
||||
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
blocks[key] = smart
|
||||
? bumpBlock(blocks[key], dir)
|
||||
: bumpArmament(blocks[key], dir);
|
||||
}
|
||||
// Tech / modernization-target inputs all use the same ±0.001 step
|
||||
// with a per-row floor; lifted into a helper so the parent can
|
||||
// reuse it (modernization area in `calculator-tab`).
|
||||
function bumpTech(event: KeyboardEvent, key: TechKey): void {
|
||||
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
const current = techs[key];
|
||||
const next = Math.round((current + dir * 0.001) * 1000) / 1000;
|
||||
const floor = techFloor[key];
|
||||
techs[key] = next < floor ? floor : next;
|
||||
}
|
||||
|
||||
const BLOCK_ROWS: {
|
||||
key: keyof DesignBlocksState;
|
||||
label: () => string;
|
||||
step: string;
|
||||
tech: TechKey | null;
|
||||
smartStep: boolean;
|
||||
}[] = [
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.1", tech: "drive", smartStep: true },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null, smartStep: false },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.1", tech: "weapons", smartStep: true },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.1", tech: "shields", smartStep: true },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.1", tech: "cargo", smartStep: true },
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
|
||||
];
|
||||
|
||||
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
|
||||
@@ -152,31 +175,27 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
<span class="label">{row.label()}</span>
|
||||
{#if isComputed}
|
||||
<input
|
||||
class="ship"
|
||||
class:smart-step={row.smartStep}
|
||||
class="ship no-spin"
|
||||
type="number"
|
||||
step={row.step}
|
||||
step="any"
|
||||
readonly
|
||||
value={resolved[row.key]}
|
||||
value={formatNumber(resolved[row.key])}
|
||||
data-computed="true"
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="ship"
|
||||
class:smart-step={row.smartStep}
|
||||
class="ship no-spin"
|
||||
type="number"
|
||||
step={row.step}
|
||||
step="any"
|
||||
min="0"
|
||||
bind:value={blocks[row.key]}
|
||||
readonly={blocksReadonly}
|
||||
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||
title={blockError(row.key)}
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
onkeydown={row.smartStep
|
||||
? (e) => onBlockKey(e, row.key)
|
||||
: null}
|
||||
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
|
||||
/>
|
||||
{/if}
|
||||
{#if row.tech !== null}
|
||||
@@ -185,14 +204,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
{#if techOverridden[techKey]}
|
||||
<input
|
||||
bind:this={techInputEls[techKey]}
|
||||
class="tech"
|
||||
class="tech no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
step="any"
|
||||
min={techFloor[techKey]}
|
||||
bind:value={techs[techKey]}
|
||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||
title={techError(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
onkeydown={(e) => bumpTech(e, techKey)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -209,7 +229,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
class="tech-val"
|
||||
data-testid={`calculator-tech-value-${techKey}`}
|
||||
>
|
||||
{techs[techKey]}
|
||||
{formatNumber(techs[techKey])}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -265,15 +285,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Drive/weapons/shields/cargo use the JS-driven smart step (0→1 jump
|
||||
then 0.1 increments) for keyboard arrows; hide the native spinner
|
||||
on those inputs so it cannot produce invalid 0.01 intermediates. */
|
||||
input.smart-step::-webkit-inner-spin-button,
|
||||
input.smart-step::-webkit-outer-spin-button {
|
||||
/* Hide native spinners across the design area — the row drives
|
||||
every numeric edit through ArrowUp/ArrowDown so the column
|
||||
width stays stable and the inputs read consistently. */
|
||||
input.no-spin::-webkit-inner-spin-button,
|
||||
input.no-spin::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.smart-step {
|
||||
input.no-spin {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user