fix(datums): dot-decimal inputs, gate Next on validity
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

Dimension inputs (width, height, length, diameter) were `type="number"`,
which let some browsers reinterpret comma-locale input ("21,5") as 21.5
under the hood. Switch to `type="text" inputmode="decimal"` and parse
strictly with `Number()` so only dot-decimal ("21.5") is treated as the
intended fraction; "21,5" produces NaN and is flagged.

A small per-field raw-input buffer remembers the literal string the user
typed so an invalid intermediate state ("21,") doesn't get rendered as
"NaN" on the next reactive pass. The buffer is dropped automatically
when the stored value diverges from `Number(buffered)` — i.e. when an
external mutation (preset button, rect-dim swap) changes the datum.

Visual: invalid fields get a red border + ring. Functional gate:
`canProceedToStep4` now also checks `Number.isFinite(...)` so a NaN
dimension prevents Next from being clickable, matching the user's
"prevent Next unless it's a number" requirement.
This commit is contained in:
Samuel Prevost 2026-05-01 00:00:29 +02:00
parent 23d3297434
commit 415058d7d8
2 changed files with 94 additions and 28 deletions

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref } from "vue"
import { useAppStore } from "@/stores/app"
import {
RECT_PRESETS,
@ -19,6 +20,53 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const store = useAppStore()
// Raw input buffers for the mm-dimension fields, keyed `${datumId}.${field}`.
// We need a separate buffer because parsing "21," (a typo or a comma-locale
// keystroke) gives NaN without this map, writing NaN to the datum and
// reading `String(d.widthMm)` back gives "NaN" in the input on next render,
// which is worse than letting the user finish typing. The buffer is dropped
// for a field whenever the stored numeric value diverges from the buffered
// parse, signalling an external mutation (preset button, rect-dim swap).
const dimRawInputs = ref(new Map<string, string>())
function dimKey(id: string, field: string): string {
return `${id}.${field}`
}
type DimField = "widthMm" | "heightMm" | "lengthMm" | "diameterMm"
function readDim(datum: Datum, field: DimField): number {
const stored = (datum as unknown as Record<string, unknown>)[field]
return typeof stored === "number" ? stored : NaN
}
function dimDisplay(datum: Datum, field: DimField): string {
const storedNum = readDim(datum, field)
const buffered = dimRawInputs.value.get(dimKey(datum.id, field))
if (buffered !== undefined) {
if (!Number.isFinite(storedNum) || Number(buffered) === storedNum) {
return buffered
}
// External mutation drop the buffer so the new stored value
// shows up on the next render.
dimRawInputs.value.delete(dimKey(datum.id, field))
}
return Number.isFinite(storedNum) ? String(storedNum) : ""
}
function dimInput(datum: Datum, field: DimField, v: string | number) {
const raw = String(v)
dimRawInputs.value.set(dimKey(datum.id, field), raw)
// Number("") and Number("21,") both return NaN the canProceedToStep4
// gate filters those out, so we don't have to here.
store.updateDatum(datum.id, { [field]: Number(raw) } as Partial<Datum>)
}
function dimValid(datum: Datum, field: DimField): boolean {
const stored = readDim(datum, field)
return Number.isFinite(stored) && stored > 0
}
function imageCenter() {
const img = store.loadedImage
if (!img) return { x: 400, y: 300 }
@ -258,15 +306,18 @@ function axisBadge(datum: Datum): string | null {
<div>
<Label class="text-xs">Width (mm)</Label>
<Input
:model-value="
String((datum as RectDatum).widthMm)
"
type="number"
min="1"
:model-value="dimDisplay(datum, 'widthMm')"
type="text"
inputmode="decimal"
class="mt-1 h-8 text-sm"
:class="
dimValid(datum, 'widthMm')
? ''
: 'border-destructive ring-2 ring-destructive/30'
"
@update:model-value="
(v: string | number) =>
updateField(datum, 'widthMm', Number(v))
dimInput(datum, 'widthMm', v)
"
@click.stop
/>
@ -299,19 +350,18 @@ function axisBadge(datum: Datum): string | null {
<div>
<Label class="text-xs">Height (mm)</Label>
<Input
:model-value="
String((datum as RectDatum).heightMm)
"
type="number"
min="1"
:model-value="dimDisplay(datum, 'heightMm')"
type="text"
inputmode="decimal"
class="mt-1 h-8 text-sm"
:class="
dimValid(datum, 'heightMm')
? ''
: 'border-destructive ring-2 ring-destructive/30'
"
@update:model-value="
(v: string | number) =>
updateField(
datum,
'heightMm',
Number(v),
)
dimInput(datum, 'heightMm', v)
"
@click.stop
/>
@ -320,13 +370,18 @@ function axisBadge(datum: Datum): string | null {
<div v-else-if="datum.type === 'line'">
<Label class="text-xs">Length (mm)</Label>
<Input
:model-value="String(datum.lengthMm)"
type="number"
min="1"
:model-value="dimDisplay(datum, 'lengthMm')"
type="text"
inputmode="decimal"
class="mt-1 h-8 text-sm"
:class="
dimValid(datum, 'lengthMm')
? ''
: 'border-destructive ring-2 ring-destructive/30'
"
@update:model-value="
(v: string | number) =>
updateField(datum, 'lengthMm', Number(v))
dimInput(datum, 'lengthMm', v)
"
@click.stop
/>
@ -334,14 +389,18 @@ function axisBadge(datum: Datum): string | null {
<div v-else>
<Label class="text-xs">Diameter (mm)</Label>
<Input
:model-value="String(datum.diameterMm)"
type="number"
min="1"
step="0.01"
:model-value="dimDisplay(datum, 'diameterMm')"
type="text"
inputmode="decimal"
class="mt-1 h-8 text-sm"
:class="
dimValid(datum, 'diameterMm')
? ''
: 'border-destructive ring-2 ring-destructive/30'
"
@update:model-value="
(v: string | number) =>
updateField(datum, 'diameterMm', Number(v))
dimInput(datum, 'diameterMm', v)
"
@click.stop
/>

View File

@ -33,9 +33,16 @@ export const useAppStore = defineStore("app", () => {
const canProceedToStep4 = computed(() => {
if (!canProceedToStep3.value || datums.value.length === 0) return false
return datums.value.every((d) => {
if (d.type === "rectangle") return d.widthMm > 0 && d.heightMm > 0
if (d.type === "line") return d.lengthMm > 0
return d.diameterMm > 0
if (d.type === "rectangle")
return (
Number.isFinite(d.widthMm) &&
d.widthMm > 0 &&
Number.isFinite(d.heightMm) &&
d.heightMm > 0
)
if (d.type === "line")
return Number.isFinite(d.lengthMm) && d.lengthMm > 0
return Number.isFinite(d.diameterMm) && d.diameterMm > 0
})
})
const canProceedToStep5 = computed(