From f547f51e8d3b23a935a8653222d002d8863342a6 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 5 Jul 2026 14:20:55 +0200 Subject: [PATCH 1/6] impr: native support for number/boolean in InputField (@fehmer) --- .../components/ui/form/InputField.spec.tsx | 17 +++++++- .../settings/custom-setting/MaxLineWidth.tsx | 14 ++----- .../src/ts/components/ui/form/InputField.tsx | 39 +++++++++++++++++-- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx index 8b079abdcf3e..0cb7a8bbbd4f 100644 --- a/frontend/__tests__/components/ui/form/InputField.spec.tsx +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -4,7 +4,10 @@ import { describe, it, expect, vi } from "vitest"; import { InputField } from "../../../../src/ts/components/ui/form/InputField"; -function makeField(name: string, value = ""): AnyFieldApi { +function makeField( + name: string, + value?: string | number | boolean, +): AnyFieldApi { return { name, state: { @@ -17,7 +20,7 @@ function makeField(name: string, value = ""): AnyFieldApi { errors: [], }, }, - options: {}, + options: { default: value }, handleBlur: vi.fn(), handleChange: vi.fn(), getMeta: () => ({ hasWarning: false, warnings: [] }), @@ -61,6 +64,16 @@ describe("InputField", () => { expect(field.handleChange).toHaveBeenCalledWith("test"); }); + it("calls handleChange on input for number", async () => { + const field = makeField("name", 2.5); + render(() => field} type="number" />); + + fireEvent.input(screen.getByRole("spinbutton"), { + target: { value: "1.25" }, + }); + expect(field.handleChange).toHaveBeenCalledWith(1.25); + }); + it("calls handleBlur on blur", async () => { const field = makeField("name"); render(() => field} />); diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx index 59e9d59cdd04..b6d91d77c46a 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx @@ -7,7 +7,6 @@ import { setConfig } from "../../../../config/setters"; import { getConfig } from "../../../../config/store"; import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; import { Setting } from "../../../common/Setting"; -// import { showSuccessNotification } from "../../../../states/notifications"; import { InputField } from "../../../ui/form/InputField"; import { fromSchema } from "../../../ui/form/utils"; @@ -19,7 +18,8 @@ export function MaxLineWidth(): JSXElement { maxLineWidth: getConfig.maxLineWidth, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.maxLineWidth)); + const val = value.maxLineWidth; + if (val === getConfig.maxLineWidth) return; flash(); setConfig("maxLineWidth", val); @@ -44,15 +44,7 @@ export function MaxLineWidth(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MaxLineWidthSchema)({ - value: val, - }); - }, + onChange: fromSchema(MaxLineWidthSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index 78c995343edf..d8b2586a546b 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -11,7 +11,16 @@ export function InputField(props: { field: Accessor; placeholder?: string; autocomplete?: string; - type?: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "range" + | "date" + | "datetime-local" + | "checkbox"; disabled?: boolean; readOnly?: boolean; clickToSelect?: boolean; @@ -68,7 +77,7 @@ export function InputField(props: { placeholder={props.placeholder ?? ""} autocomplete={props.autocomplete} name={props.field().name as string} - value={(props.field().state.value as string) ?? ""} + value={convertValueToString(props.field().state.value)} onBlur={() => { if ( props.resetToDefaultIfEmptyOnBlur && @@ -83,7 +92,11 @@ export function InputField(props: { props.field().handleBlur(); }} onInput={(e) => { - props.field().handleChange(e.target.value); + const value: unknown = convertStringToValue( + props.field().state.value, + e.target.value, + ); + props.field().handleChange(value); }} onKeyDown={(e) => { if (e.key === "Enter") { @@ -147,3 +160,23 @@ function getDateOptions( max: applyFormat((schema as ZodDate).maxDate), }; } + +function convertValueToString(input: unknown | undefined): string { + if (input === undefined || input === null) return ""; + if (typeof input === "boolean") return input ? "true" : "false"; + if (typeof input === "number") return input.toString(); + return input as string; +} + +function convertStringToValue( + defaultValue: T, + newValue: string, +): T { + if (defaultValue === undefined || defaultValue === null) return newValue as T; + if (typeof defaultValue === "boolean") { + return (newValue === "true" || newValue === "1") as T; + } + if (typeof defaultValue === "number") return Number.parseFloat(newValue) as T; + + return newValue as T; +} From d24e50e6f61c73d71fd58f399a6f49b0c47b737d Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 5 Jul 2026 14:33:54 +0200 Subject: [PATCH 2/6] more --- .../components/modals/CustomGeneratorModal.tsx | 12 ++++++------ .../components/modals/CustomWordAmountModal.tsx | 16 ++++++---------- .../components/pages/settings/SettingsPage.tsx | 16 ++++------------ .../pages/settings/custom-setting/MinAcc.tsx | 12 ++---------- .../pages/settings/custom-setting/MinBurst.tsx | 12 ++---------- .../pages/settings/custom-setting/MinSpeed.tsx | 10 ++-------- .../pages/settings/custom-setting/PaceCaret.tsx | 12 ++---------- 7 files changed, 24 insertions(+), 66 deletions(-) diff --git a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx index 4d7631f8fb13..0394765d7025 100644 --- a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx +++ b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx @@ -104,9 +104,9 @@ export function CustomGeneratorModal(props: { const form = createForm(() => ({ defaultValues: { characterSet: "", - minLength: "2", - maxLength: "5", - wordCount: "100", + minLength: 2, + maxLength: 5, + wordCount: 100, }, onSubmit: ({ value }) => { const input = value.characterSet.trim(); @@ -116,9 +116,9 @@ export function CustomGeneratorModal(props: { } const characters = input.split(/\s+/); - const minLength = parseInt(value.minLength) || 2; - const maxLength = parseInt(value.maxLength) || 5; - const wordCount = parseInt(value.wordCount) || 100; + const minLength = value.minLength || 2; + const maxLength = value.maxLength || 5; + const wordCount = value.wordCount || 100; const generatedWords: string[] = []; for (let i = 0; i < wordCount; i++) { diff --git a/frontend/src/ts/components/modals/CustomWordAmountModal.tsx b/frontend/src/ts/components/modals/CustomWordAmountModal.tsx index 57e6ced0a9f2..766b037dbd08 100644 --- a/frontend/src/ts/components/modals/CustomWordAmountModal.tsx +++ b/frontend/src/ts/components/modals/CustomWordAmountModal.tsx @@ -1,5 +1,6 @@ import { createForm } from "@tanstack/solid-form"; import { JSXElement } from "solid-js"; +import { z } from "zod"; import { setConfig } from "../../config/setters"; import { getConfig } from "../../config/store"; @@ -9,15 +10,15 @@ import { showNoticeNotification } from "../../states/notifications"; import { AnimatedModal } from "../common/AnimatedModal"; import { InputField } from "../ui/form/InputField"; import { SubmitButton } from "../ui/form/SubmitButton"; +import { fromSchema } from "../ui/form/utils"; export function CustomWordAmountModal(): JSXElement { const form = createForm(() => ({ defaultValues: { - words: getConfig.words.toString(), + words: getConfig.words, }, onSubmit: ({ value }) => { - const val = parseInt(value.words, 10); - + const val = value.words; setConfig("words", val); restartTestEvent.dispatch(); @@ -40,7 +41,7 @@ export function CustomWordAmountModal(): JSXElement { title="Custom word amount" focusFirstInput="focusAndSelect" beforeShow={() => { - form.reset({ words: getConfig.words.toString() }); + form.reset({ words: getConfig.words }); }} >
{ - const val = parseInt(value, 10); - if (isNaN(val) || !isFinite(val)) return "Must be a number"; - if (val < 0) return "Must be non-negative"; - return undefined; - }, + onChange: fromSchema(z.number().nonnegative().safe()), }} children={(field) => ( diff --git a/frontend/src/ts/components/pages/settings/SettingsPage.tsx b/frontend/src/ts/components/pages/settings/SettingsPage.tsx index df344727ae74..4f4b91b777be 100644 --- a/frontend/src/ts/components/pages/settings/SettingsPage.tsx +++ b/frontend/src/ts/components/pages/settings/SettingsPage.tsx @@ -324,7 +324,7 @@ function AutoSetting(props: { [props.key]: getConfig[props.key], }, onSubmit: ({ value }) => { - const val = parseFloat(String(value[props.key])); + const val = value[props.key]; if (val === getConfig[props.key]) return; savedIndicator.flash(); setConfig(props.key, val as Config[T]); @@ -349,17 +349,9 @@ function AutoSetting(props: { //@ts-expect-error what name={props.key} validators={{ - onChange: ({ value }) => { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema( - ConfigSchema.shape[props.key] as z.ZodNumber, - )({ - value: val, - }); - }, + onChange: fromSchema( + ConfigSchema.shape[props.key] as z.ZodNumber, + ), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx index 7db767a927a7..432cfc04bcd5 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx @@ -20,7 +20,7 @@ export function MinAcc(): JSXElement { minAccCustom: getConfig.minAccCustom, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.minAccCustom)); + const val = value.minAccCustom; if (val === getConfig.minAccCustom) return; if (getConfig.minAcc === "custom") { // @@ -50,15 +50,7 @@ export function MinAcc(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MinimumAccuracyCustomSchema)({ - value: val, - }); - }, + onChange: fromSchema(MinimumAccuracyCustomSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx index 8600b2a825ca..2a45024c4442 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx @@ -20,7 +20,7 @@ export function MinBurst(): JSXElement { minBurstCustomSpeed: getConfig.minBurstCustomSpeed, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.minBurstCustomSpeed)); + const val = value.minBurstCustomSpeed; if (val === getConfig.minBurstCustomSpeed) return; if (getConfig.minBurst !== "off") { // @@ -50,15 +50,7 @@ export function MinBurst(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MinimumBurstCustomSpeedSchema)({ - value: val, - }); - }, + onChange: fromSchema(MinimumBurstCustomSpeedSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx index 34a21200f0af..be315fe88965 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx @@ -20,7 +20,7 @@ export function MinSpeed(): JSXElement { minWpmCustomSpeed: getConfig.minWpmCustomSpeed, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.minWpmCustomSpeed)); + const val = value.minWpmCustomSpeed; if (val === getConfig.minWpmCustomSpeed) return; if (getConfig.minWpm === "custom") { // @@ -50,13 +50,7 @@ export function MinSpeed(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MinWpmCustomSpeedSchema)({ value: val }); - }, + onChange: fromSchema(MinWpmCustomSpeedSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx index ea019e169907..b655f343ee85 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx @@ -23,7 +23,7 @@ export function PaceCaret(): JSXElement { paceCaretCustomSpeed: getConfig.paceCaretCustomSpeed, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.paceCaretCustomSpeed)); + const val = value.paceCaretCustomSpeed; if (val === getConfig.paceCaretCustomSpeed) return; if (getConfig.paceCaret !== "off") { // @@ -53,15 +53,7 @@ export function PaceCaret(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(PaceCaretCustomSpeedSchema)({ - value: val, - }); - }, + onChange: fromSchema(PaceCaretCustomSpeedSchema), onBlur: () => { void form.handleSubmit(); }, From 52fe55edb5ef842649e3493304793d61520709c8 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 5 Jul 2026 14:36:52 +0200 Subject: [PATCH 3/6] trigger From a85313836ddb191c5031c38a320955b4fda636ba Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 7 Jul 2026 12:15:41 +0200 Subject: [PATCH 4/6] fix handling of nan, revert back to default on blur --- .../components/ui/form/InputField.spec.tsx | 66 ++++++++++++++++--- .../src/ts/components/ui/form/InputField.tsx | 21 +++--- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx index 0cb7a8bbbd4f..aaa221edf9d1 100644 --- a/frontend/__tests__/components/ui/form/InputField.spec.tsx +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -8,21 +8,28 @@ function makeField( name: string, value?: string | number | boolean, ): AnyFieldApi { + let current = value; + const meta = { + isValidating: false, + isTouched: false, + isValid: true, + isDefaultValue: true, + errors: [] as string[], + }; return { name, - state: { - value, - meta: { - isValidating: false, - isTouched: false, - isValid: true, - isDefaultValue: true, - errors: [], - }, + get state() { + return { + value: current, + meta, + }; }, options: { default: value }, handleBlur: vi.fn(), - handleChange: vi.fn(), + handleChange: vi.fn((v: unknown) => { + current = v as typeof value; + }), + setValue: vi.fn(), getMeta: () => ({ hasWarning: false, warnings: [] }), } as unknown as AnyFieldApi; } @@ -113,4 +120,43 @@ describe("InputField", () => { expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument(); }); + + it("resets to default value on blur when empty for type number", async () => { + const field = makeField("age", 25); + field.form = { options: { defaultValues: { age: 25 } } } as any; + render(() => ( + field} + type="number" + resetToDefaultIfEmptyOnBlur + /> + )); + + fireEvent.input(screen.getByRole("spinbutton"), { + target: { value: "" }, + }); + fireEvent.blur(screen.getByRole("spinbutton")); + expect(field.setValue).toHaveBeenCalledWith(25); + }); + + it("resets to default value on blur when empty for type string", async () => { + const field = makeField("name", "Alice"); + field.form = { options: { defaultValues: { name: "Alice" } } } as any; + render(() => ( + field} resetToDefaultIfEmptyOnBlur /> + )); + + fireEvent.input(screen.getByRole("textbox"), { + target: { value: "" }, + }); + fireEvent.blur(screen.getByRole("textbox")); + expect(field.setValue).toHaveBeenCalledWith("Alice"); + }); + + it("renders NaN as empty string for type number", async () => { + const field = makeField("value", NaN); + render(() => field} type="number" />); + + expect(screen.getByRole("spinbutton").getAttribute("value")).toBeNull(); + }); }); diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index d8b2586a546b..29865df0c15c 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -11,16 +11,7 @@ export function InputField(props: { field: Accessor; placeholder?: string; autocomplete?: string; - type?: - | "text" - | "textarea" - | "password" - | "email" - | "number" - | "range" - | "date" - | "datetime-local" - | "checkbox"; + type?: string; disabled?: boolean; readOnly?: boolean; clickToSelect?: boolean; @@ -81,7 +72,7 @@ export function InputField(props: { onBlur={() => { if ( props.resetToDefaultIfEmptyOnBlur && - props.field().state.value === "" + props.field().state.value === undefined ) { props.field().setValue( // oxlint-disable-next-line typescript/no-unsafe-member-access @@ -164,18 +155,22 @@ function getDateOptions( function convertValueToString(input: unknown | undefined): string { if (input === undefined || input === null) return ""; if (typeof input === "boolean") return input ? "true" : "false"; - if (typeof input === "number") return input.toString(); + if (typeof input === "number") { + if (isFinite(input)) return input.toString(); + else return ""; + } return input as string; } function convertStringToValue( defaultValue: T, newValue: string, -): T { +): T | undefined { if (defaultValue === undefined || defaultValue === null) return newValue as T; if (typeof defaultValue === "boolean") { return (newValue === "true" || newValue === "1") as T; } + if (newValue === "") return undefined; if (typeof defaultValue === "number") return Number.parseFloat(newValue) as T; return newValue as T; From 3e253436581eb8d72982ff48927997c3f0200b06 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 7 Jul 2026 12:37:37 +0200 Subject: [PATCH 5/6] trigger From 31a3fc5a2a4fb0858d6773341c94f1fc1b4a7dc7 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 7 Jul 2026 12:44:05 +0200 Subject: [PATCH 6/6] cleanup --- frontend/src/ts/components/modals/CustomGeneratorModal.tsx | 6 +++--- frontend/src/ts/components/ui/form/InputField.tsx | 7 ++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx index 0394765d7025..5c94924b5976 100644 --- a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx +++ b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx @@ -116,9 +116,9 @@ export function CustomGeneratorModal(props: { } const characters = input.split(/\s+/); - const minLength = value.minLength || 2; - const maxLength = value.maxLength || 5; - const wordCount = value.wordCount || 100; + const minLength = value.minLength ?? 2; + const maxLength = value.maxLength ?? 5; + const wordCount = value.wordCount ?? 100; const generatedWords: string[] = []; for (let i = 0; i < wordCount; i++) { diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index 03d38cdb7dc9..ad080d184cab 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -73,7 +73,8 @@ export function InputField(props: { onBlur={() => { if ( props.resetToDefaultIfEmptyOnBlur && - props.field().state.value === undefined + (props.field().state.value === undefined || + props.field().state.value === "") ) { props.field().setValue( // oxlint-disable-next-line typescript/no-unsafe-member-access @@ -158,7 +159,6 @@ function getDateOptions( function convertValueToString(input: unknown | undefined): string { if (input === undefined || input === null) return ""; - if (typeof input === "boolean") return input ? "true" : "false"; if (typeof input === "number") { if (isFinite(input)) return input.toString(); else return ""; @@ -171,9 +171,6 @@ function convertStringToValue( newValue: string, ): T | undefined { if (defaultValue === undefined || defaultValue === null) return newValue as T; - if (typeof defaultValue === "boolean") { - return (newValue === "true" || newValue === "1") as T; - } if (newValue === "") return undefined; if (typeof defaultValue === "number") return Number.parseFloat(newValue) as T;