Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/number-input-dual-separator.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<v-number-input
v-if="inputProps.type === 'number'"
:id="inputProps.id"
Expand All @@ -104,10 +106,11 @@
:label="inputProps.label"
:error-messages="inputProps.errorMessages"
:error="inputProps.error"
:precision="inputProps.refinement === 'int' ? 0 : null"
:precision="null"
control-variant="stacked"
v-bind="$attrs"
:model-value="state.value as any"
@beforeinput.capture="onNumberBeforeinput"
@update:model-value="(v: number | null) => field.handleChange((v ?? undefined) as any)"
>
<template
Expand Down Expand Up @@ -270,7 +273,9 @@
generic="From extends Record<PropertyKey, any>, Name extends DeepKeys<From>"
>
import { type DeepKeys } from "@tanstack/vue-form"
import { computed, watchEffect } from "vue"
import { computed, useAttrs, watchEffect } from "vue"
import { useLocale } from "vuetify"
import { handleDecimalSeparatorBeforeinput } from "./decimalSeparatorInput"
import type { VuetifyInputProps } from "./InputProps"
import { getInputType } from "./inputs"
import { typeOverrides } from "./types"
Expand All @@ -286,6 +291,26 @@ defineOptions({
inheritAttrs: false
})

const attrs = useAttrs()
// useLocale throws outside a Vuetify app (e.g. tests mounting with stubbed
// vuetify components); numbers then fall back to the "." separator.
const localeDecimalSeparator = (() => {
try {
return useLocale().decimalSeparator
} catch {
return undefined
}
})()

// Mirrors VNumberInput's own resolution: an explicit decimal-separator attr
// wins, otherwise the locale decides.
const decimalSeparator = computed(() => {
const explicit = (attrs["decimal-separator"] ?? attrs["decimalSeparator"]) as string | undefined
return explicit?.[0] || localeDecimalSeparator?.value || "."
})

const onNumberBeforeinput = (e: Event) => handleDecimalSeparatorBeforeinput(e as InputEvent, decimalSeparator.value)

// True when no dedicated branch handles `inputProps.type` (it's outside the
// built-in `typeOverrides`): the fallback text input renders and we warn.
const isUnhandledType = computed(() => !(typeOverrides as readonly string[]).includes(props.inputProps.type))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const escapeForRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")

/**
* VNumberInput only accepts the active decimal separator and silently drops
* the other one while typing. Attached in capture phase on the component
* root, this handler lets users type either "." or ",": the wrong character
* is translated to the active separator before Vuetify's own beforeinput
* filter can reject it.
*
* No validation beyond Vuetify's own single-separator typing rule: OmegaForm
* deliberately lets out-of-schema values through so schema errors show,
* instead of silently blocking input (same reasoning as min/max not being
* passed as props).
*/
export const handleDecimalSeparatorBeforeinput = (e: InputEvent, separator: string) => {
if (!e.data) return
// The active separator may be any single char (locale or explicit prop,
// e.g. "٫"); everything from [".", ","] that isn't it gets translated.
const wrongs = [".", ","].filter((c) => c !== separator)
if (!wrongs.some((c) => e.data!.includes(c))) return

const input = e.target as HTMLInputElement
e.preventDefault()
e.stopPropagation()

const data = wrongs.reduce((acc, c) => acc.replaceAll(c, separator), e.data)
const start = input.selectionStart ?? input.value.length
const end = input.selectionEnd ?? input.value.length
const next = input.value.slice(0, start) + data + input.value.slice(end)
// Mirrors Vuetify's own typing filter: at most one separator, "-" only at
// the start. A second separator would only produce an unparseable string.
if (!new RegExp(`^-?\\d*${escapeForRegex(separator)}?\\d*$`).test(next)) return

input.value = next
const cursor = start + data.length
input.setSelectionRange(cursor, cursor)
input.dispatchEvent(new Event("input", { bubbles: true }))
}
10 changes: 6 additions & 4 deletions packages/vue-components/stories/OmegaForm/NumberInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
<template #default="{ subscribedValues: { values } }">
<pre>{{ values }}</pre>

<!-- S.Int with min/max from the schema: precision 0 is automatic (no decimal
separator); out-of-range values (e.g. 21) show the schema validation error,
the bounds are exposed to assistive tech via aria-valuemin/valuemax -->
<!-- S.Int with min/max from the schema: nothing is silently blocked, typing a
decimal (e.g. 1.5) or an out-of-range value (e.g. 21) shows the schema
validation error; the bounds are exposed to assistive tech via
aria-valuemin/valuemax -->
<form.Input
name="quantity"
label="Quantity (1-20)"
Expand All @@ -28,7 +29,8 @@
/>

<!-- decimal separator follows the Vuetify locale (this Storybook is "en", so "." elsewhere);
it can be forced per field, here comma: typing "." is rejected, "," starts the decimals -->
it can be forced per field, here comma. Typing either "." or "," works: the wrong
one is translated to the active separator instead of being rejected -->
<form.Input
name="commaPrice"
label="Comma price (decimal-separator ,)"
Expand Down
Loading