Skip to content

Commit 96fd7f5

Browse files
authored
feat(VNumberInput): show error state when out of range (#21825)
1 parent bc31d9a commit 96fd7f5

File tree

2 files changed

+89
-3
lines changed

2 files changed

+89
-3
lines changed

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
131131
)
132132

133133
const _inputText = shallowRef<string | null>(null)
134+
const _lastParsedValue = shallowRef<number | null>(null)
134135
watchEffect(() => {
135136
if (
136137
isFocused.value &&
@@ -144,6 +145,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
144145
_inputText.value = null
145146
} else if (!isNaN(model.value)) {
146147
_inputText.value = correctPrecision(model.value)
148+
_lastParsedValue.value = Number(_inputText.value.replace(decimalSeparator.value, '.'))
147149
}
148150
})
149151
const inputText = computed<string | null>({
@@ -155,13 +157,23 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
155157
return
156158
}
157159
const parsedValue = Number(val.replace(decimalSeparator.value, '.'))
158-
if (!isNaN(parsedValue) && parsedValue <= props.max && parsedValue >= props.min) {
159-
model.value = parsedValue
160+
if (!isNaN(parsedValue)) {
160161
_inputText.value = val
162+
_lastParsedValue.value = parsedValue
163+
164+
if (parsedValue <= props.max && parsedValue >= props.min) {
165+
model.value = parsedValue
166+
}
161167
}
162168
},
163169
})
164170

171+
const isOutOfRange = computed(() => {
172+
if (!_lastParsedValue.value) return false
173+
const numberFromText = Number(_lastParsedValue.value)
174+
return numberFromText !== clamp(numberFromText, props.min, props.max)
175+
})
176+
165177
const canIncrease = computed(() => {
166178
if (controlsDisabled.value) return false
167179
return (model.value ?? 0) as number + props.step <= props.max
@@ -474,6 +486,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
474486
v-model={ inputText.value }
475487
v-model:focused={ isFocused.value }
476488
validationValue={ model.value }
489+
error={ isOutOfRange.value || undefined }
477490
onBeforeinput={ onBeforeinput }
478491
onFocus={ onFocus }
479492
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)