diff --git a/.changeset/number-input-dual-separator.md b/.changeset/number-input-dual-separator.md new file mode 100644 index 000000000..336fd94f6 --- /dev/null +++ b/.changeset/number-input-dual-separator.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": patch +--- + +OmegaForm number fields accept both "." and "," while typing: the wrong separator is translated to the active one (locale or explicit decimal-separator). Int fields no longer silently block decimals (precision null): invalid values go through and the schema error shows. diff --git a/packages/vue-components/__tests__/OmegaForm/DecimalSeparatorInput.test.ts b/packages/vue-components/__tests__/OmegaForm/DecimalSeparatorInput.test.ts new file mode 100644 index 000000000..9b2d7045c --- /dev/null +++ b/packages/vue-components/__tests__/OmegaForm/DecimalSeparatorInput.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest" +import { handleDecimalSeparatorBeforeinput } from "../../src/components/OmegaForm/decimalSeparatorInput" + +// Simulates typing into a VNumberInput: the handler is attached in capture +// phase on the component root, the native input is the event target. +const setup = ( + value: string, + separator: string, + cursor?: { start: number; end: number } +) => { + const root = document.createElement("div") + const input = document.createElement("input") + root.appendChild(input) + document.body.appendChild(root) + input.value = value + const start = cursor?.start ?? value.length + const end = cursor?.end ?? value.length + input.setSelectionRange(start, end) + + let vuetifySawBeforeinput = false + let inputEventFired = false + input.addEventListener("beforeinput", () => { + vuetifySawBeforeinput = true + }) + input.addEventListener("input", () => { + inputEventFired = true + }) + root.addEventListener( + "beforeinput", + (e) => handleDecimalSeparatorBeforeinput(e, separator), + { capture: true } + ) + + const type = (data: string) => { + const e = new InputEvent("beforeinput", { + data, + inputType: "insertText", + bubbles: true, + cancelable: true + }) + input.dispatchEvent(e) + return e + } + + return { + input, + type, + sawVuetifyHandler: () => vuetifySawBeforeinput, + sawInputEvent: () => inputEventFired + } +} + +describe("handleDecimalSeparatorBeforeinput", () => { + it("translates '.' into ',' when the active separator is ','", () => { + const t = setup("1", ",") + const e = t.type(".") + expect(e.defaultPrevented).toBe(true) + expect(t.sawVuetifyHandler()).toBe(false) + expect(t.input.value).toBe("1,") + expect(t.sawInputEvent()).toBe(true) + }) + + it("translates ',' into '.' when the active separator is '.'", () => { + const t = setup("1", ".") + t.type(",") + expect(t.input.value).toBe("1.") + }) + + it("translates both '.' and ',' when the active separator is a custom one", () => { + const dot = setup("1", "٫") + dot.type(".") + expect(dot.input.value).toBe("1٫") + + const comma = setup("2", "٫") + comma.type(",") + expect(comma.input.value).toBe("2٫") + }) + + it("leaves the event alone when the typed char is the active separator", () => { + const t = setup("1", ",") + const e = t.type(",") + expect(e.defaultPrevented).toBe(false) + expect(t.sawVuetifyHandler()).toBe(true) + expect(t.input.value).toBe("1") + }) + + it("leaves plain digits alone", () => { + const t = setup("1", ",") + const e = t.type("5") + expect(e.defaultPrevented).toBe(false) + expect(t.sawVuetifyHandler()).toBe(true) + }) + + it("swallows the wrong separator when the value already has one", () => { + const t = setup("1,5", ",") + const e = t.type(".") + expect(e.defaultPrevented).toBe(true) + expect(t.input.value).toBe("1,5") + expect(t.sawInputEvent()).toBe(false) + }) + + it("inserts at the cursor position and replaces the selection", () => { + const t = setup("15", ",", { start: 1, end: 1 }) + t.type(".") + expect(t.input.value).toBe("1,5") + expect(t.input.selectionStart).toBe(2) + expect(t.input.selectionEnd).toBe(2) + }) + + it("normalizes the wrong separator inside pasted data", () => { + const t = setup("", ",") + t.type("1.5") + expect(t.input.value).toBe("1,5") + }) +}) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue b/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue index 6e677a4a4..80e193057 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue @@ -91,7 +91,9 @@ silently withholds out-of-range values from the model (so schema errors would never show) and clamps on blur. Validation stays schema-driven; the bounds are exposed to AT via the spinbutton ARIA attrs, which fall through - to the native input. --> + to the native input. Same reasoning for precision: Vuetify's default (0) + would silently block decimals, so it's forced to null and typing 1.5 into + an int field shows the schema error instead. -->