Skip to content

Commit 534c1e7

Browse files
J-Sekjohnleider
andauthored
feat(VNumberInput): custom decimal separator (#21489)
Co-authored-by: John Leider <[email protected]> resolves #20254
1 parent 30ac0fc commit 534c1e7

File tree

8 files changed

+180
-50
lines changed

8 files changed

+180
-50
lines changed

packages/api-generator/src/locale/en/VNumberInput.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"props": {
33
"controlVariant": "The color of the control. It defaults to the value of `variant` prop.",
4+
"decimalSeparator": "Expects single character to be used as decimal separator.",
45
"hideInput": "Hide the input field.",
56
"inset": "Applies an indentation to the dividers used in the stepper buttons.",
67
"max": "Specifies the maximum allowable value for the input.",

packages/vuetify/src/components/VNumberInput/VNumberInput.tsx

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextFi
1111
import { useHold } from './hold'
1212
import { useForm } from '@/composables/form'
1313
import { forwardRefs } from '@/composables/forwardRefs'
14+
import { useLocale } from '@/composables/locale'
1415
import { useProxiedModel } from '@/composables/proxiedModel'
1516

1617
// Utilities
1718
import { computed, nextTick, onMounted, ref, shallowRef, toRef, watch, watchEffect } from 'vue'
18-
import { clamp, extractNumber, genericComponent, omit, propsFactory, useRender } from '@/util'
19+
import { clamp, escapeForRegex, extractNumber, genericComponent, omit, propsFactory, useRender } from '@/util'
1920

2021
// Types
2122
import type { PropType } from 'vue'
@@ -63,6 +64,10 @@ const makeVNumberInputProps = propsFactory({
6364
type: Number as PropType<number | null>,
6465
default: null,
6566
},
67+
decimalSeparator: {
68+
type: String,
69+
validator: (v: any) => !v || v.length === 1,
70+
},
6671

6772
...omit(makeVTextFieldProps(), ['modelValue', 'validationValue']),
6873
}, 'VNumberInput')
@@ -90,27 +95,32 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
9095

9196
const isFocused = shallowRef(props.focused)
9297

93-
function correctPrecision (val: number, precision = props.precision) {
94-
if (precision == null) {
95-
return String(val)
96-
}
98+
const { decimalSeparator: decimalSeparatorFromLocale } = useLocale()
99+
const decimalSeparator = computed(() => props.decimalSeparator?.[0] || decimalSeparatorFromLocale.value)
97100

98-
let fixed = val.toFixed(precision)
101+
function correctPrecision (val: number, precision = props.precision, trim = true) {
102+
const fixed = precision == null
103+
? String(val)
104+
: val.toFixed(precision)
99105

100-
if (isFocused.value) {
106+
if (isFocused.value && trim) {
101107
return Number(fixed).toString() // trim zeros
108+
.replace('.', decimalSeparator.value)
102109
}
103110

104-
if ((props.minFractionDigits ?? precision) < precision) {
105-
const trimLimit = precision - props.minFractionDigits!
106-
const [baseDigits, fractionDigits] = fixed.split('.')
107-
fixed = [
108-
baseDigits,
109-
fractionDigits.replace(new RegExp(`0{1,${trimLimit}}$`), ''),
110-
].filter(Boolean).join('.')
111+
if (props.minFractionDigits === null || (precision !== null && precision < props.minFractionDigits)) {
112+
return fixed.replace('.', decimalSeparator.value)
111113
}
112114

113-
return fixed
115+
let [baseDigits, fractionDigits] = fixed.split('.')
116+
117+
fractionDigits = (fractionDigits ?? '').padEnd(props.minFractionDigits, '0')
118+
.replace(new RegExp(`(?<=\\d{${props.minFractionDigits}})0`, 'g'), '')
119+
120+
return [
121+
baseDigits,
122+
fractionDigits,
123+
].filter(Boolean).join(decimalSeparator.value)
114124
}
115125

116126
const model = useProxiedModel(props, 'modelValue', null,
@@ -136,8 +146,11 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
136146
if (val === null || val === '') {
137147
model.value = null
138148
_inputText.value = null
139-
} else if (!isNaN(Number(val)) && Number(val) <= props.max && Number(val) >= props.min) {
140-
model.value = Number(val)
149+
return
150+
}
151+
const parsedValue = Number(val.replace(decimalSeparator.value, '.'))
152+
if (!isNaN(parsedValue) && parsedValue <= props.max && parsedValue >= props.min) {
153+
model.value = parsedValue
141154
_inputText.value = val
142155
}
143156
},
@@ -218,25 +231,25 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
218231
? existingTxt.slice(0, selectionStart as number | undefined) + e.data + existingTxt.slice(selectionEnd as number | undefined)
219232
: e.data
220233

221-
const potentialNewNumber = extractNumber(potentialNewInputVal, props.precision)
234+
const potentialNewNumber = extractNumber(potentialNewInputVal, props.precision, decimalSeparator.value)
222235

223-
// Only numbers, "-", "." are allowed
224-
// AND "-", "." are allowed only once
225-
// AND "-" is only allowed at the start
226-
if (!/^-?(\d+(\.\d*)?|(\.\d+)|\d*|\.)$/.test(potentialNewInputVal)) {
236+
// Allow only numbers, "-" and {decimal separator}
237+
// Allow "-" and {decimal separator} only once
238+
// Allow "-" only at the start
239+
if (!new RegExp(`^-?\\d*${escapeForRegex(decimalSeparator.value)}?\\d*$`).test(potentialNewInputVal)) {
227240
e.preventDefault()
228241
inputElement!.value = potentialNewNumber
229242
}
230243

231244
if (props.precision == null) return
232245

233246
// Ignore decimal digits above precision limit
234-
if (potentialNewInputVal.split('.')[1]?.length > props.precision) {
247+
if (potentialNewInputVal.split(decimalSeparator.value)[1]?.length > props.precision) {
235248
e.preventDefault()
236249
inputElement!.value = potentialNewNumber
237250
}
238251
// Ignore decimal separator when precision = 0
239-
if (props.precision === 0 && potentialNewInputVal.includes('.')) {
252+
if (props.precision === 0 && potentialNewInputVal.includes(decimalSeparator.value)) {
240253
e.preventDefault()
241254
inputElement!.value = potentialNewNumber
242255
}
@@ -299,20 +312,19 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
299312
if (controlsDisabled.value) return
300313
if (!vTextFieldRef.value) return
301314
const actualText = vTextFieldRef.value.value
302-
if (actualText && !isNaN(Number(actualText))) {
303-
inputText.value = correctPrecision(clamp(Number(actualText), props.min, props.max))
315+
const parsedValue = Number(actualText.replace(decimalSeparator.value, '.'))
316+
if (actualText && !isNaN(parsedValue)) {
317+
inputText.value = correctPrecision(clamp(parsedValue, props.min, props.max))
304318
} else {
305319
inputText.value = null
306320
}
307321
}
308322

309323
function formatInputValue () {
310324
if (controlsDisabled.value) return
311-
if (model.value === null || isNaN(model.value)) {
312-
inputText.value = null
313-
return
314-
}
315-
inputText.value = correctPrecision(model.value)
325+
inputText.value = model.value !== null && !isNaN(model.value)
326+
? correctPrecision(model.value, props.precision, false)
327+
: null
316328
}
317329

318330
function trimDecimalZeros () {
@@ -322,6 +334,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
322334
return
323335
}
324336
inputText.value = model.value.toString()
337+
.replace('.', decimalSeparator.value)
325338
}
326339

327340
function onFocus () {

packages/vuetify/src/components/VNumberInput/__tests__/VNumberInput.spec.browser.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,34 @@ describe('VNumberInput', () => {
203203
await expect.element(screen.getByCSS('input')).toHaveValue('0.00')
204204
expect(model.value).toBe(0)
205205
})
206+
207+
it('shows custom decimal separator when incrementing', async () => {
208+
const model = ref(0)
209+
render(() => (
210+
<VNumberInput
211+
step={ 0.07 }
212+
precision={ 2 }
213+
decimalSeparator=","
214+
v-model={ model.value }
215+
/>
216+
))
217+
218+
await userEvent.click(screen.getByTestId('increment'))
219+
await expect.element(screen.getByCSS('input')).toHaveValue('0,07')
220+
expect(model.value).toBe(0.07)
221+
222+
await userEvent.click(screen.getByTestId('increment'))
223+
await expect.element(screen.getByCSS('input')).toHaveValue('0,14')
224+
expect(model.value).toBe(0.14)
225+
226+
await userEvent.click(screen.getByTestId('decrement'))
227+
await expect.element(screen.getByCSS('input')).toHaveValue('0,07')
228+
expect(model.value).toBe(0.07)
229+
230+
await userEvent.click(screen.getByTestId('decrement'))
231+
await expect.element(screen.getByCSS('input')).toHaveValue('0,00')
232+
expect(model.value).toBe(0)
233+
})
206234
})
207235

208236
it('should not fire @update:focus twice when clicking bottom of input', async () => {
@@ -241,5 +269,56 @@ describe('VNumberInput', () => {
241269
input.blur()
242270
expect(model.value).toBe(expected)
243271
})
272+
273+
it.each([
274+
{ sep: ',', precision: 0, text: '-00123', expected: -123 },
275+
{ sep: ',', precision: 2, text: ',250', expected: 0.25 },
276+
{ sep: ',', precision: 3, text: '000,321', expected: 0.321 },
277+
{ sep: ',', precision: 0, text: '100,99', expected: 100 },
278+
{ sep: ',', precision: 1, text: '200,99', expected: 200.9 },
279+
{ sep: ',', precision: 2, text: ' 1,250.32\n', expected: 1.25 },
280+
{ sep: ',', precision: 0, text: '1\'024.00 meters', expected: 102400 },
281+
{ sep: ',', precision: 0, text: '- 1123.', expected: -1123 },
282+
{ sep: ',', precision: 0, text: '- 32,', expected: -32 },
283+
])('should parse numbers with custom separator', async ({ sep, precision, text, expected }) => {
284+
const model = ref(null)
285+
const { element } = render(() => (
286+
<VNumberInput
287+
v-model={ model.value }
288+
decimalSeparator={ sep }
289+
precision={ precision }
290+
/>
291+
))
292+
const input = element.querySelector('input') as HTMLInputElement
293+
input.focus()
294+
navigator.clipboard.writeText(text)
295+
await userEvent.paste()
296+
input.blur()
297+
expect(model.value).toBe(expected)
298+
})
299+
})
300+
301+
describe('fraction digits control', () => {
302+
it.each([
303+
{ precision: 2, minFractionDigits: null, typing: '.312', expected: '0.31' },
304+
{ precision: 2, minFractionDigits: null, typing: '12.', expected: '12.00' },
305+
{ precision: 0, minFractionDigits: 0, typing: '42', expected: '42' },
306+
{ precision: 0, minFractionDigits: 1, typing: '-1.321', expected: '-1321' }, // dot is ignored while typing
307+
{ precision: 0, minFractionDigits: 1, typing: '2', expected: '2' },
308+
{ precision: 0, minFractionDigits: 3, typing: '31.9', expected: '319' }, // dot is ignored while typing
309+
{ precision: 5, minFractionDigits: 3, typing: '-92.21', expected: '-92.210' },
310+
{ precision: 5, minFractionDigits: 3, typing: '-92.2132', expected: '-92.2132' },
311+
{ precision: 5, minFractionDigits: 3, typing: '-92.21325555', expected: '-92.21325' },
312+
{ precision: null, minFractionDigits: 0, typing: '8', expected: '8' },
313+
{ precision: null, minFractionDigits: 1, typing: '8', expected: '8.0' },
314+
{ precision: null, minFractionDigits: 2, typing: '-1.5', expected: '-1.50' },
315+
{ precision: null, minFractionDigits: 2, typing: '-1.521', expected: '-1.521' },
316+
])('applies flexible limit on fraction digits', async ({ precision, minFractionDigits, typing, expected }) => {
317+
const { element } = render(() => <VNumberInput precision={ precision } minFractionDigits={ minFractionDigits } />)
318+
await userEvent.click(element)
319+
await userEvent.keyboard(typing)
320+
await userEvent.click(document.body)
321+
expect(screen.getByCSS('input')).toHaveValue(expected)
322+
})
244323
})
245324
})

packages/vuetify/src/composables/locale.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { computed, inject, provide, ref, toRef } from 'vue'
33
import { createVuetifyAdapter } from '@/locale/adapters/vuetify'
44

55
// Types
6-
import type { InjectionKey, Ref } from 'vue'
6+
import type { InjectionKey, Ref, ShallowRef } from 'vue'
77

88
export interface LocaleMessages {
99
[key: string]: LocaleMessages | string
1010
}
1111

1212
export interface LocaleOptions {
13+
decimalSeparator?: string
1314
messages?: LocaleMessages
1415
locale?: string
1516
fallback?: string
@@ -18,6 +19,7 @@ export interface LocaleOptions {
1819

1920
export interface LocaleInstance {
2021
name: string
22+
decimalSeparator: ShallowRef<string>
2123
messages: Ref<LocaleMessages>
2224
current: Ref<string>
2325
fallback: Ref<string>

packages/vuetify/src/locale/adapters/vue-i18n.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { useProxiedModel } from '@/composables/proxiedModel'
33

44
// Utilities
5-
import { watch } from 'vue'
5+
import { toRef, watch } from 'vue'
66

77
// Types
88
import type { Ref } from 'vue'
@@ -28,6 +28,10 @@ function useProvided <T> (props: any, prop: string, provided: Ref<T>) {
2828
return internal as Ref<T>
2929
}
3030

31+
function inferDecimalSeparator (format: (v: number) => string) {
32+
return format(0.1).includes(',') ? ',' : '.'
33+
}
34+
3135
function createProvideFunction (data: {
3236
current: Ref<string>
3337
fallback: Ref<string>
@@ -57,6 +61,7 @@ function createProvideFunction (data: {
5761
current,
5862
fallback,
5963
messages,
64+
decimalSeparator: toRef(() => props.decimalSeparator ?? inferDecimalSeparator(i18n.n)),
6065
t: (key: string, ...params: unknown[]) => i18n.t(key, params),
6166
n: i18n.n,
6267
provide: createProvideFunction({ current, fallback, messages, useI18n: data.useI18n }),
@@ -74,6 +79,7 @@ export function createVueI18nAdapter ({ i18n, useI18n }: VueI18nAdapterParams):
7479
current,
7580
fallback,
7681
messages,
82+
decimalSeparator: toRef(() => inferDecimalSeparator(i18n.global.n)),
7783
t: (key: string, ...params: unknown[]) => i18n.global.t(key, params),
7884
n: i18n.global.n,
7985
provide: createProvideFunction({ current, fallback, messages, useI18n }),

packages/vuetify/src/locale/adapters/vuetify.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { useProxiedModel } from '@/composables/proxiedModel'
33

44
// Utilities
5-
import { ref, shallowRef, watch } from 'vue'
5+
import { ref, shallowRef, toRef, watch } from 'vue'
66
import { consoleError, consoleWarn, getObjectValueByPath } from '@/util'
77

88
// Locales
@@ -63,6 +63,11 @@ function createNumberFunction (current: Ref<string>, fallback: Ref<string>) {
6363
}
6464
}
6565

66+
function inferDecimalSeparator (current: Ref<string>, fallback: Ref<string>) {
67+
const format = createNumberFunction(current, fallback)
68+
return format(0.1).includes(',') ? ',' : '.'
69+
}
70+
6671
function useProvided <T> (props: any, prop: string, provided: Ref<T>) {
6772
const internal = useProxiedModel(props, prop, props[prop] ?? provided.value)
6873

@@ -89,6 +94,7 @@ function createProvideFunction (state: { current: Ref<string>, fallback: Ref<str
8994
current,
9095
fallback,
9196
messages,
97+
decimalSeparator: toRef(() => inferDecimalSeparator(current, fallback)),
9298
t: createTranslateFunction(current, fallback, messages),
9399
n: createNumberFunction(current, fallback),
94100
provide: createProvideFunction({ current, fallback, messages }),
@@ -106,6 +112,7 @@ export function createVuetifyAdapter (options?: LocaleOptions): LocaleInstance {
106112
current,
107113
fallback,
108114
messages,
115+
decimalSeparator: toRef(() => options?.decimalSeparator ?? inferDecimalSeparator(current, fallback)),
109116
t: createTranslateFunction(current, fallback, messages),
110117
n: createNumberFunction(current, fallback),
111118
provide: createProvideFunction({ current, fallback, messages }),

packages/vuetify/src/util/__tests__/helpers.spec.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -377,15 +377,29 @@ describe('helpers', () => {
377377

378378
describe('extractNumber', () => {
379379
it('should parse valid number out of text', () => {
380-
expect(extractNumber(' 2,142,400.50 ', 2)).toBe('2142400.50')
381-
expect(extractNumber(' 100 %', 1)).toBe('100')
382-
expect(extractNumber(' .4099 ', 2)).toBe('.40')
383-
expect(extractNumber('v: 15.00 ', 0)).toBe('15')
384-
expect(extractNumber('$ 2,132.00', 2)).toBe('2132.00')
385-
expect(extractNumber('$ 32.00', 2)).toBe('32.00')
386-
expect(extractNumber(' -6.67 USD', 2)).toBe('-6.67')
387-
expect(extractNumber('($9,000.00)', 2)).toBe('9000.00')
388-
expect(extractNumber(' 23 567.20 ', 2)).toBe('23567.20')
380+
// dot
381+
expect(extractNumber(' 2,142,400.50 ', 2, '.')).toBe('2142400.50')
382+
expect(extractNumber(' 100 %', 1, '.')).toBe('100')
383+
expect(extractNumber(' .4099 ', 2, '.')).toBe('.40')
384+
expect(extractNumber('v: 15.00 ', 0, '.')).toBe('15')
385+
expect(extractNumber('$ 2,132.00', 2, '.')).toBe('2132.00')
386+
expect(extractNumber('$ 32.00', 2, '.')).toBe('32.00')
387+
expect(extractNumber(' -6.67 USD', 2, '.')).toBe('-6.67')
388+
expect(extractNumber('($9,000.00)', 2, '.')).toBe('9000.00')
389+
expect(extractNumber(' 23 567.20 ', 2, '.')).toBe('23567.20')
390+
expect(extractNumber('-200.99 ', 1, '.')).toBe('-200.9')
391+
392+
// comma
393+
expect(extractNumber(' 2,142,400.50 ', 2, ',')).toBe('2,14')
394+
expect(extractNumber(' 100 %', 1, ',')).toBe('100')
395+
expect(extractNumber(' ,4099 ', 2, ',')).toBe(',40')
396+
expect(extractNumber('v: 15.00 ', 0, ',')).toBe('1500')
397+
expect(extractNumber('$ 2,132.00', 2, ',')).toBe('2,13')
398+
expect(extractNumber('$ 32,00', 2, ',')).toBe('32,00')
399+
expect(extractNumber(' -6,67 USD', 2, ',')).toBe('-6,67')
400+
expect(extractNumber('($9.000,00)', 2, ',')).toBe('9000,00')
401+
expect(extractNumber(' 23 567,20 ', 2, ',')).toBe('23567,20')
402+
expect(extractNumber('-200,99 ', 1, ',')).toBe('-200,9')
389403
})
390404
})
391405

0 commit comments

Comments
 (0)