Skip to content

Commit 2e2cddb

Browse files
committed
feat(VNumberInput): show error state when out of range (#21825)
1 parent bb15873 commit 2e2cddb

File tree

2 files changed

+100
-10
lines changed

2 files changed

+100
-10
lines changed

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { useLocale } from '@/composables/locale'
1515
import { useProxiedModel } from '@/composables/proxiedModel'
1616

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

2121
// Types
@@ -131,37 +131,53 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
131131
)
132132

133133
const _inputText = shallowRef<string | null>(null)
134-
watchEffect(() => {
134+
const _lastParsedValue = shallowRef<number | null>(null)
135+
136+
watch(model, val => {
135137
if (
136138
isFocused.value &&
137139
!controlsDisabled.value &&
138-
Number(_inputText.value) === model.value
140+
Number(_inputText.value) === val
139141
) {
140142
// ignore external changes while typing
141143
// e.g. 5.01{backspace}2 » should result in 5.02
142144
// but we emit '5' in and want to preserve '5.0'
143-
} else if (model.value == null) {
145+
} else if (val == null) {
144146
_inputText.value = null
145-
} else if (!isNaN(model.value)) {
146-
_inputText.value = correctPrecision(model.value)
147+
_lastParsedValue.value = null
148+
} else if (!isNaN(val)) {
149+
_inputText.value = correctPrecision(val)
150+
_lastParsedValue.value = Number(_inputText.value.replace(decimalSeparator.value, '.'))
147151
}
148-
})
152+
}, { immediate: true })
153+
149154
const inputText = computed<string | null>({
150155
get: () => _inputText.value,
151156
set (val) {
152157
if (val === null || val === '') {
153158
model.value = null
154159
_inputText.value = null
160+
_lastParsedValue.value = null
155161
return
156162
}
157163
const parsedValue = Number(val.replace(decimalSeparator.value, '.'))
158-
if (!isNaN(parsedValue) && parsedValue <= props.max && parsedValue >= props.min) {
159-
model.value = parsedValue
164+
if (!isNaN(parsedValue)) {
160165
_inputText.value = val
166+
_lastParsedValue.value = parsedValue
167+
168+
if (parsedValue <= props.max && parsedValue >= props.min) {
169+
model.value = parsedValue
170+
}
161171
}
162172
},
163173
})
164174

175+
const isOutOfRange = computed(() => {
176+
if (_lastParsedValue.value === null) return false
177+
const numberFromText = Number(_inputText.value)
178+
return numberFromText !== clamp(numberFromText, props.min, props.max)
179+
})
180+
165181
const canIncrease = computed(() => {
166182
if (controlsDisabled.value) return false
167183
return (model.value ?? 0) as number + props.step <= props.max
@@ -474,6 +490,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
474490
v-model={ inputText.value }
475491
v-model:focused={ isFocused.value }
476492
validationValue={ model.value }
493+
error={ isOutOfRange.value || undefined }
477494
onBeforeinput={ onBeforeinput }
478495
onFocus={ onFocus }
479496
onBlur={ onBlur }

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { VForm } from '@/components/VForm'
44

55
// Utilities
66
import { render, screen, userEvent } from '@test'
7-
import { ref } from 'vue'
7+
import { nextTick, ref } from 'vue'
88

99
describe('VNumberInput', () => {
1010
it.each([
@@ -341,4 +341,77 @@ describe('VNumberInput', () => {
341341
expect(screen.getByCSS('input')).toHaveValue(expected)
342342
})
343343
})
344+
345+
describe('should indicate range error', () => {
346+
// enable in 4.0.0
347+
it.todo('on mount', async () => {
348+
const model = ref(-13)
349+
const onChange = vi.fn()
350+
render(() => (
351+
<VNumberInput
352+
v-model={ model.value }
353+
min={ 5 }
354+
onUpdate:modelValue={ onChange }
355+
/>
356+
))
357+
358+
await nextTick()
359+
expect(model.value).toBe(-13)
360+
expect(onChange).not.toHaveBeenCalled()
361+
expect(screen.getByCSS('.v-input')).toHaveClass('v-input--error')
362+
})
363+
364+
it('while typing', async () => {
365+
const model = ref(null)
366+
const onChange = vi.fn()
367+
render(() => (
368+
<VNumberInput
369+
v-model={ model.value }
370+
min={ 5 }
371+
onUpdate:modelValue={ onChange }
372+
/>
373+
))
374+
375+
const vInput = screen.getByCSS('.v-input')
376+
377+
await userEvent.tab()
378+
await userEvent.keyboard('1')
379+
expect(vInput).toHaveClass('v-input--error')
380+
381+
await userEvent.keyboard('2')
382+
expect(vInput).not.toHaveClass('v-input--error')
383+
expect(model.value).toBe(12)
384+
385+
await userEvent.keyboard('{arrowLeft}{arrowLeft}-')
386+
expect(vInput).toHaveClass('v-input--error')
387+
})
388+
389+
it('while typing', async () => {
390+
const model = ref(0)
391+
const onChange = vi.fn()
392+
render(() => (
393+
<VNumberInput
394+
v-model={ model.value }
395+
max={ 50 }
396+
onUpdate:modelValue={ onChange }
397+
/>
398+
))
399+
400+
await nextTick()
401+
const vInput = screen.getByCSS('.v-input')
402+
expect(vInput).not.toHaveClass('v-input--error')
403+
404+
model.value = 50.55 // will be rounded to 51
405+
await nextTick()
406+
expect(vInput).toHaveClass('v-input--error')
407+
408+
model.value = 99
409+
await nextTick()
410+
expect(vInput).toHaveClass('v-input--error')
411+
412+
model.value = 45
413+
await nextTick()
414+
expect(vInput).not.toHaveClass('v-input--error')
415+
})
416+
})
344417
})

0 commit comments

Comments
 (0)