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:
@@ -323,6 +323,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.lock = null;
|
||||
}
|
||||
|
||||
// Generic ±step keyboard handler for the calculator's free-form
|
||||
// number inputs (MAT, custom-load, lock value, modernization
|
||||
// target tech). Pairs with `class="no-spin"` so the native spinner
|
||||
// is hidden everywhere and the column width is stable; ArrowUp /
|
||||
// ArrowDown is the only step affordance. The smart 0↔1 jump on
|
||||
// the ship-class blocks lives in `ship-design-area.svelte` —
|
||||
// these other inputs accept any non-negative number.
|
||||
function onStepKey(
|
||||
event: KeyboardEvent,
|
||||
current: number,
|
||||
step: number,
|
||||
min: number,
|
||||
apply: (next: number) => void,
|
||||
): void {
|
||||
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
// Snap to the same fractional grid as `step` so 0.001 stays
|
||||
// at three decimals instead of drifting via float math.
|
||||
const inv = 1 / step;
|
||||
const next = Math.round((current + dir * step) * inv) / inv;
|
||||
apply(next < min ? min : next);
|
||||
}
|
||||
|
||||
function loadExisting(clsName: string): void {
|
||||
const cls = localShipClass.find((c) => c.name === clsName);
|
||||
if (cls === undefined) return;
|
||||
@@ -338,6 +362,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.lock = null;
|
||||
}
|
||||
|
||||
// Compare the live blocks to the baseline they were last loaded
|
||||
// from — or to the empty defaults if no class has been loaded. The
|
||||
// dropdown selection flow uses this to ask before discarding manual
|
||||
// edits. Tech overrides are independent of class loading, so they
|
||||
// don't count as "dirty" here.
|
||||
function baselineBlocks(): {
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
} {
|
||||
if (cs.loadedExisting !== null) {
|
||||
const cls = localShipClass.find((c) => c.name === cs.loadedExisting);
|
||||
if (cls !== undefined) {
|
||||
return {
|
||||
drive: cls.drive,
|
||||
armament: cls.armament,
|
||||
weapons: cls.weapons,
|
||||
shields: cls.shields,
|
||||
cargo: cls.cargo,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||
}
|
||||
function isDesignDirty(): boolean {
|
||||
const base = baselineBlocks();
|
||||
return (
|
||||
cs.blocks.drive !== base.drive ||
|
||||
cs.blocks.armament !== base.armament ||
|
||||
cs.blocks.weapons !== base.weapons ||
|
||||
cs.blocks.shields !== base.shields ||
|
||||
cs.blocks.cargo !== base.cargo
|
||||
);
|
||||
}
|
||||
function tryLoadByName(name: string): void {
|
||||
const cls = localShipClass.find((c) => c.name === name);
|
||||
if (cls === undefined) return;
|
||||
if (cs.loadedExisting === cls.name) return;
|
||||
if (isDesignDirty()) {
|
||||
const ok = window.confirm(
|
||||
i18n.t("game.calculator.confirm_reset_for_load", {
|
||||
name: cls.name,
|
||||
}),
|
||||
);
|
||||
if (!ok) {
|
||||
cs.name = cs.loadedExisting ?? "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadExisting(name);
|
||||
}
|
||||
// Catch the datalist option click immediately. Native `change` only
|
||||
// fires on blur in Firefox, which is what made dropdown selection
|
||||
// look delayed; `input` fires the moment the value is set. Typed
|
||||
// keystrokes carry an `inputType` ("insertText", "deleteContent…");
|
||||
// a datalist selection replaces the value in one shot, so its
|
||||
// `inputType` is undefined (Firefox) or "insertReplacementText"
|
||||
// (Chromium / WebKit). We treat that as a selection.
|
||||
function onNameInput(event: Event): void {
|
||||
const ev = event as InputEvent;
|
||||
const isSelection =
|
||||
ev.inputType === undefined ||
|
||||
ev.inputType === "insertReplacementText";
|
||||
if (!isSelection) {
|
||||
cs.loadedExisting = null;
|
||||
return;
|
||||
}
|
||||
tryLoadByName(cs.name);
|
||||
}
|
||||
|
||||
// React to the ship-classes table / bottom-tabs asking to load a
|
||||
// class (or start a fresh design) into the calculator. The layout
|
||||
// flips the sidebar to this tab in parallel.
|
||||
@@ -394,9 +490,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.lock === output}
|
||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||
<input
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
step="any"
|
||||
bind:value={cs.lockValue}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
|
||||
data-testid={`calculator-locked-${output}`}
|
||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||
/>
|
||||
@@ -462,8 +561,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||
maxlength="30"
|
||||
bind:value={cs.name}
|
||||
oninput={() => (cs.loadedExisting = null)}
|
||||
onchange={() => loadExisting(cs.name)}
|
||||
oninput={onNameInput}
|
||||
onchange={() => tryLoadByName(cs.name)}
|
||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||
data-testid="calculator-name"
|
||||
/>
|
||||
@@ -503,6 +602,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
techOverridden={cs.techOverridden}
|
||||
techFloor={playerTech}
|
||||
computedInput={result.computedInput}
|
||||
formatNumber={fmt}
|
||||
{onTechInput}
|
||||
{onResetTech}
|
||||
/>
|
||||
@@ -526,10 +626,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.loadMode === "custom"}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
min="0"
|
||||
class="custom-load"
|
||||
class="custom-load no-spin"
|
||||
bind:value={cs.customLoad}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
|
||||
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||
title={customLoadError}
|
||||
data-testid="calculator-custom-load"
|
||||
@@ -609,10 +711,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
{#if cs.matOverridden}
|
||||
<input
|
||||
bind:this={matInputRef.el}
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.01"
|
||||
step="any"
|
||||
min="0"
|
||||
bind:value={cs.matValue}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
|
||||
aria-invalid={matError !== "" ? "true" : "false"}
|
||||
title={matError}
|
||||
data-testid="calculator-planet-mat"
|
||||
@@ -632,7 +737,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
class="mat-val"
|
||||
data-testid="calculator-planet-mat-value"
|
||||
>
|
||||
{cs.matValue}
|
||||
{fmt(cs.matValue)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -677,10 +782,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
class="no-spin"
|
||||
type="number"
|
||||
step="0.001"
|
||||
step="any"
|
||||
min={playerTech[row.key]}
|
||||
bind:value={cs.targetTech[row.key]}
|
||||
onkeydown={(e) =>
|
||||
onStepKey(
|
||||
e,
|
||||
cs.targetTech[row.key],
|
||||
0.001,
|
||||
playerTech[row.key],
|
||||
(v) => (cs.targetTech[row.key] = v),
|
||||
)}
|
||||
aria-invalid={targetError !== "" ? "true" : "false"}
|
||||
title={targetError}
|
||||
data-testid={`calculator-target-${row.key}`}
|
||||
@@ -852,6 +966,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
/* Hide the native spinner on every calculator number input — the
|
||||
row drives every numeric edit through ArrowUp / ArrowDown so the
|
||||
column width is stable and the inputs read consistently with the
|
||||
ship-block row inside the design area. */
|
||||
input.no-spin::-webkit-inner-spin-button,
|
||||
input.no-spin::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.no-spin {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.cell.locked input {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
|
||||
Reference in New Issue
Block a user