Skip to content

Commit 3a31086

Browse files
yuwu9145KaelWD
andauthored
fix(VNumberInput): prevent NaN & handle js number quirks (#20211)
fixes #19798 fixes #20171 Co-authored-by: Kael <[email protected]>
1 parent 0a31bf8 commit 3a31086

File tree

3 files changed

+117
-42
lines changed

3 files changed

+117
-42
lines changed

packages/docs/src/pages/en/components/number-inputs.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ Here we display a list of settings that could be applied within an application.
5656

5757
<ApiInline hide-links />
5858

59+
## Caveats
60+
61+
::: warning
62+
**v-number-input** is designed for simple numeric input usage. It has limitations with very long integers and highly precise decimal arithmetic due to JavaScript number precision issues:
63+
64+
- For integers, **v-model** is restricted within [Number.MIN_SAFE_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MIN_SAFE_INTEGER) and [Number.MAX_SAFE_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER) to ensure precision is not lost.
65+
66+
- To cope with JavaScript floating-point issues (e.g. 0.1 + 0.2 === 0.30000000000000004), Vuetify's internal logic uses **toFixed()** with the maximum number of decimal places between v-model and step. If accurate arbitrary-precision decimal arithmetic is required, consider working with strings using [decimal.js](https://github.com/MikeMcl/decimal.js) and [v-text-field](/components/text-fields) instead.
67+
:::
68+
5969
## Guide
6070

6171
The `v-number-input` component is built upon the `v-field` and `v-input` components. It is used as a replacement for `<input type="number">`, accepting numeric values from the user.

packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx

Lines changed: 72 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useForm } from '@/composables/form'
1212
import { useProxiedModel } from '@/composables/proxiedModel'
1313

1414
// Utilities
15-
import { computed, watchEffect } from 'vue'
15+
import { computed, nextTick, onMounted, ref } from 'vue'
1616
import { clamp, genericComponent, getDecimals, omit, propsFactory, useRender } from '@/util'
1717

1818
// Types
@@ -37,20 +37,24 @@ const makeVNumberInputProps = propsFactory({
3737
},
3838
inset: Boolean,
3939
hideInput: Boolean,
40+
modelValue: {
41+
type: Number as PropType<Number | null>,
42+
default: null,
43+
},
4044
min: {
4145
type: Number,
42-
default: -Infinity,
46+
default: Number.MIN_SAFE_INTEGER,
4347
},
4448
max: {
4549
type: Number,
46-
default: Infinity,
50+
default: Number.MAX_SAFE_INTEGER,
4751
},
4852
step: {
4953
type: Number,
5054
default: 1,
5155
},
5256

53-
...omit(makeVTextFieldProps(), ['appendInnerIcon', 'prependInnerIcon']),
57+
...omit(makeVTextFieldProps({}), ['appendInnerIcon', 'modelValue', 'prependInnerIcon']),
5458
}, 'VNumberInput')
5559

5660
export const VNumberInput = genericComponent<VNumberInputSlots>()({
@@ -64,11 +68,20 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
6468
'update:modelValue': (val: number) => true,
6569
},
6670

67-
setup (props, { attrs, emit, slots }) {
68-
const model = useProxiedModel(props, 'modelValue')
71+
setup (props, { slots }) {
72+
const _model = useProxiedModel(props, 'modelValue')
73+
74+
const model = computed({
75+
get: () => _model.value,
76+
set (val) {
77+
if (typeof val !== 'string') _model.value = val
78+
},
79+
})
80+
81+
const vTextFieldRef = ref<VTextField | undefined>()
6982

7083
const stepDecimals = computed(() => getDecimals(props.step))
71-
const modelDecimals = computed(() => model.value != null ? getDecimals(model.value) : 0)
84+
const modelDecimals = computed(() => typeof model.value === 'number' ? getDecimals(model.value) : 0)
7285

7386
const form = useForm()
7487
const controlsDisabled = computed(() => (
@@ -77,20 +90,11 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
7790

7891
const canIncrease = computed(() => {
7992
if (controlsDisabled.value) return false
80-
if (model.value == null) return true
81-
return model.value + props.step <= props.max
93+
return (model.value ?? 0) as number + props.step <= props.max
8294
})
8395
const canDecrease = computed(() => {
8496
if (controlsDisabled.value) return false
85-
if (model.value == null) return true
86-
return model.value - props.step >= props.min
87-
})
88-
89-
watchEffect(() => {
90-
if (controlsDisabled.value) return
91-
if (model.value != null && (model.value < props.min || model.value > props.max)) {
92-
model.value = clamp(model.value, props.min, props.max)
93-
}
97+
return (model.value ?? 0) as number - props.step >= props.min
9498
})
9599

96100
const controlVariant = computed(() => {
@@ -106,18 +110,24 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
106110

107111
const decrementSlotProps = computed(() => ({ click: onClickDown }))
108112

113+
onMounted(() => {
114+
if (!props.readonly && !props.disabled) {
115+
clampModel()
116+
}
117+
})
118+
109119
function toggleUpDown (increment = true) {
110120
if (controlsDisabled.value) return
111121
if (model.value == null) {
112-
model.value = 0
122+
model.value = clamp(0, props.min, props.max)
113123
return
114124
}
115125

116126
const decimals = Math.max(modelDecimals.value, stepDecimals.value)
117127
if (increment) {
118-
if (canIncrease.value) model.value = +(((model.value + props.step).toFixed(decimals)))
128+
if (canIncrease.value) model.value = +((((model.value as number) + props.step).toFixed(decimals)))
119129
} else {
120-
if (canDecrease.value) model.value = +(((model.value - props.step).toFixed(decimals)))
130+
if (canDecrease.value) model.value = +((((model.value as number) - props.step).toFixed(decimals)))
121131
}
122132
}
123133

@@ -131,37 +141,56 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
131141
toggleUpDown(false)
132142
}
133143

134-
function onKeydown (e: KeyboardEvent) {
144+
function onBeforeinput (e: InputEvent) {
145+
if (!e.data) return
146+
const existingTxt = (e.target as HTMLInputElement)?.value
147+
const selectionStart = (e.target as HTMLInputElement)?.selectionStart
148+
const selectionEnd = (e.target as HTMLInputElement)?.selectionEnd
149+
const potentialNewInputVal =
150+
existingTxt
151+
? existingTxt.slice(0, selectionStart as number | undefined) + e.data + existingTxt.slice(selectionEnd as number | undefined)
152+
: e.data
153+
// Only numbers, "-", "." are allowed
154+
// AND "-", "." are allowed only once
155+
// AND "-" is only allowed at the start
156+
if (!/^-?(\d+(\.\d*)?|(\.\d+)|\d*|\.)$/.test(potentialNewInputVal)) {
157+
e.preventDefault()
158+
}
159+
}
160+
161+
async function onKeydown (e: KeyboardEvent) {
135162
if (
136163
['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab'].includes(e.key) ||
137164
e.ctrlKey
138165
) return
139166

140-
if (['ArrowDown'].includes(e.key)) {
141-
e.preventDefault()
142-
toggleUpDown(false)
143-
return
144-
}
145-
if (['ArrowUp'].includes(e.key)) {
146-
e.preventDefault()
147-
toggleUpDown()
148-
return
149-
}
150-
151-
// Only numbers, +, - & . are allowed
152-
if (!/^[0-9\-+.]+$/.test(e.key)) {
167+
if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
153168
e.preventDefault()
169+
clampModel()
170+
// _model is controlled, so need to wait until props['modelValue'] is updated
171+
await nextTick()
172+
if (e.key === 'ArrowDown') {
173+
toggleUpDown(false)
174+
} else {
175+
toggleUpDown()
176+
}
154177
}
155178
}
156179

157-
function onModelUpdate (v: string) {
158-
model.value = v ? +(v) : undefined
159-
}
160-
161180
function onControlMousedown (e: MouseEvent) {
162181
e.stopPropagation()
163182
}
164183

184+
function clampModel () {
185+
if (!vTextFieldRef.value) return
186+
const inputText = vTextFieldRef.value.value
187+
if (inputText && !isNaN(+inputText)) {
188+
model.value = clamp(+(inputText), props.min, props.max)
189+
} else {
190+
model.value = null
191+
}
192+
}
193+
165194
useRender(() => {
166195
const { modelValue: _, ...textFieldProps } = VTextField.filterProps(props)
167196

@@ -277,8 +306,10 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
277306

278307
return (
279308
<VTextField
280-
modelValue={ model.value }
281-
onUpdate:modelValue={ onModelUpdate }
309+
ref={ vTextFieldRef }
310+
v-model={ model.value }
311+
onBeforeinput={ onBeforeinput }
312+
onChange={ clampModel }
282313
onKeydown={ onKeydown }
283314
class={[
284315
'v-number-input',

packages/vuetify/src/labs/VNumberInput/__tests__/VNumberInput.spec.cy.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,40 @@ import { VForm } from '@/components/VForm'
88
import { ref } from 'vue'
99

1010
describe('VNumberInput', () => {
11+
it('should prevent NaN from arbitrary input', () => {
12+
const scenarios = [
13+
{ typing: '---', expected: '-' }, // "-" is only allowed once
14+
{ typing: '1-', expected: '1' }, // "-" is only at the start
15+
{ typing: '.', expected: '.' }, // "." is allowed at the start
16+
{ typing: '..', expected: '.' }, // "." is only allowed once
17+
{ typing: '1...0', expected: '1.0' }, // "." is only allowed once
18+
{ typing: '123.45.67', expected: '123.4567' }, // "." is only allowed once
19+
{ typing: 'ab-c8+.iop9', expected: '-8.9' }, // Only numbers, "-", "." are allowed to type in
20+
]
21+
scenarios.forEach(({ typing, expected }) => {
22+
cy.mount(() => <VNumberInput />)
23+
.get('.v-number-input input').focus().realType(typing)
24+
.get('.v-number-input input').should('have.value', expected)
25+
})
26+
})
27+
28+
it('should reset v-model to null when click:clear is triggered', () => {
29+
const model = ref(5)
30+
31+
cy.mount(() => (
32+
<>
33+
<VNumberInput
34+
clearable
35+
v-model={ model.value }
36+
readonly
37+
/>
38+
</>
39+
))
40+
.get('.v-field__clearable .v-icon--clickable').click()
41+
.then(() => {
42+
expect(model.value).equal(null)
43+
})
44+
})
1145
describe('readonly', () => {
1246
it('should prevent mutation when readonly applied', () => {
1347
const value = ref(1)
@@ -98,7 +132,7 @@ describe('VNumberInput', () => {
98132
class="disabled-input-2"
99133
v-model={ value4.value }
100134
min={ 0 }
101-
max={ 10 }
135+
max={ 10 }
102136
disabled
103137
/>
104138
</>

0 commit comments

Comments
 (0)