Skip to content

Commit

Permalink
feat(VNumberInput): add parsing and fallback for min/max values
Browse files Browse the repository at this point in the history
Enhanced the `VNumberInput` component to include parsing logic for `min` and `max` properties. Implemented automatic fallback to `Number.MIN_SAFE_INTEGER` and `Number.MAX_SAFE_INTEGER` when values are non-numeric or exceed safe integer ranges. This ensures consistent and reliable behavior under edge cases.

resolves vuetifyjs#20788
  • Loading branch information
Yuval.D authored and Yuval.D committed Dec 18, 2024
1 parent 1d34a83 commit d68f6b0
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 44 deletions.
73 changes: 73 additions & 0 deletions packages/vuetify/playgrounds/Playground.number.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue';
// References for testing various boundary conditions
const value1 = ref(20); // Test for max within range
const value2 = ref(Number.MAX_SAFE_INTEGER + 1); // Test for exceeding max value
const value3 = ref(Number.MAX_SAFE_INTEGER + 1); // Test for unparseable max value
const value4 = ref(2); // Test for min within range
const value5 = ref(Number.MIN_SAFE_INTEGER - 1); // Test for exceeding min value
const value6 = ref(Number.MIN_SAFE_INTEGER - 1); // Test for unparseable min value
// Constants for boundary values
const minValue = 5;
const maxValue = 15;
</script>

<template>
<v-app>
<v-container>
<!-- Test case: Max value within range -->
<v-number-input
class="max-within-range"
:max="maxValue"
v-model="value1"
/>

<!-- Test case: Max value exceeds safe integer -->
<v-number-input
class="max-out-of-range1"
:max="Number.MAX_SAFE_INTEGER + 2"
v-model="value2"
/>

<!-- Test case: Max value set to "Infinity" -->
<v-number-input
class="max-out-of-range2"
max="Infinity"
v-model="value3"
/>

<!-- Test case: Min value within range -->
<v-number-input
class="min-within-range"
:min="minValue"
v-model="value4"
/>

<!-- Test case: Min value below safe integer -->
<v-number-input
class="min-out-of-range1"
:min="Number.MIN_SAFE_INTEGER - 2"
v-model="value5"
/>

<!-- Test case: Min value set to "-Infinity" -->
<v-number-input
class="min-out-of-range2"
min="-Infinity"
v-model="value6"
/>

<!-- Testing VNumberInput with various styles, icons, and options -->
<v-number-input prepend-inner-icon="mdi-alert" reverse />
<v-number-input prepend-inner-icon="mdi-square" hide-input />
<v-number-input hide-input />
<v-number-input prepend-inner-icon="mdi-circle-outline" />
<v-select prepend-inner-icon="mdi-check" placeholder="Normal padding" />


</v-container>
</v-app>
</template>
17 changes: 10 additions & 7 deletions packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ const makeVNumberInputProps = propsFactory({
default: null,
},
min: {
type: Number,
type: [Number, String],
default: Number.MIN_SAFE_INTEGER,
},
max: {
type: Number,
type: [Number, String],
default: Number.MAX_SAFE_INTEGER,
},
step: {
Expand All @@ -72,6 +72,9 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
setup (props, { slots }) {
const _model = useProxiedModel(props, 'modelValue')

const min = computed(() => Math.max(Number.isFinite(parseFloat(props.min)) ? parseFloat(props.min) : Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER))
const max = computed(() => Math.min(Number.isFinite(parseFloat(props.max)) ? parseFloat(props.max) : Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER))

const model = computed({
get: () => _model.value,
// model.value could be empty string from VTextField
Expand All @@ -83,7 +86,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
}

const value = Number(val)
if (!isNaN(value) && value <= props.max && value >= props.min) {
if (!isNaN(value) && value <= max.value && value >= min.value) {
_model.value = value
}
},
Expand All @@ -101,11 +104,11 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({

const canIncrease = computed(() => {
if (controlsDisabled.value) return false
return (model.value ?? 0) as number + props.step <= props.max
return (model.value ?? 0) as number + props.step <= max.value
})
const canDecrease = computed(() => {
if (controlsDisabled.value) return false
return (model.value ?? 0) as number - props.step >= props.min
return (model.value ?? 0) as number - props.step >= min.value
})

const controlVariant = computed(() => {
Expand All @@ -130,7 +133,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
function toggleUpDown (increment = true) {
if (controlsDisabled.value) return
if (model.value == null) {
model.value = clamp(0, props.min, props.max)
model.value = clamp(0, min.value, max.value)
return
}

Expand Down Expand Up @@ -196,7 +199,7 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
if (!vTextFieldRef.value) return
const inputText = vTextFieldRef.value.value
if (inputText && !isNaN(+inputText)) {
model.value = clamp(+(inputText), props.min, props.max)
model.value = clamp(+(inputText), min.value, max.value)
} else {
model.value = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { VForm } from '@/components/VForm'

// Utilities
import { render, screen, userEvent } from '@test'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'


describe('VNumberInput', () => {
it.each([
Expand All @@ -14,7 +15,7 @@ describe('VNumberInput', () => {
{ typing: '..', expected: '.' }, // "." is only allowed once
{ typing: '1...0', expected: '1.0' }, // "." is only allowed once
{ typing: '123.45.67', expected: '123.4567' }, // "." is only allowed once
{ typing: 'ab-c8+.iop9', expected: '-8.9' }, // Only numbers, "-", "." are allowed to type in
{ typing: 'ab-c8+.iop9', expected: '-8.9' } // Only numbers, "-", "." are allowed to type in
])('prevents NaN from arbitrary input', async ({ typing, expected }) => {
const { element } = render(VNumberInput)
await userEvent.click(element)
Expand All @@ -27,7 +28,7 @@ describe('VNumberInput', () => {
render(() => (
<VNumberInput
clearable
v-model={ model.value }
v-model={model.value}
readonly
/>
))
Expand All @@ -41,9 +42,9 @@ describe('VNumberInput', () => {
const model = ref(null)
const { element } = render(() => (
<VNumberInput
v-model={ model.value }
min={ 5 }
max={ 125 }
v-model={model.value}
min={5}
max={125}
/>
))

Expand All @@ -69,7 +70,7 @@ describe('VNumberInput', () => {
const model = ref(1)

const { element } = render(() => (
<VNumberInput v-model={ model.value } readonly />
<VNumberInput v-model={model.value} readonly/>
))

await userEvent.click(screen.getByTestId('increment'))
Expand All @@ -91,7 +92,7 @@ describe('VNumberInput', () => {

const { element } = render(() => (
<VForm readonly>
<VNumberInput v-model={ model.value } />
<VNumberInput v-model={model.value}/>
</VForm>
))

Expand Down Expand Up @@ -119,30 +120,30 @@ describe('VNumberInput', () => {
<>
<VNumberInput
class="readonly-input-1"
v-model={ value1.value }
min={ 0 }
max={ 50 }
v-model={value1.value}
min={0}
max={50}
readonly
/>
<VNumberInput
class="readonly-input-2"
v-model={ value2.value }
min={ 0 }
max={ 50 }
v-model={value2.value}
min={0}
max={50}
readonly
/>
<VNumberInput
class="disabled-input-1"
v-model={ value3.value }
min={ 0 }
max={ 10 }
v-model={value3.value}
min={0}
max={10}
disabled
/>
<VNumberInput
class="disabled-input-2"
v-model={ value4.value }
min={ 0 }
max={ 10 }
v-model={value4.value}
min={0}
max={10}
disabled
/>
</>
Expand All @@ -155,33 +156,69 @@ describe('VNumberInput', () => {
})
})

describe('native number input quirks', () => {
it('should not bypass min', async () => {
const model = ref(1)
render(() =>
<VNumberInput min={ 5 } max={ 15 } v-model={ model.value } />
)
describe('boundary value handling', () => {
it('should respect max value and fallback to max safe integer if max is out of range or cannot be parsed', () => {
const value1 = ref(20)
const value2 = ref(Number.MAX_SAFE_INTEGER + 1)
const value3 = ref(Number.MAX_SAFE_INTEGER + 1)

render(() => (
<>
<VNumberInput max={15} v-model={value1.value} class="max-within-range"/>
<VNumberInput max={Number.MAX_SAFE_INTEGER + 2} v-model={value2.value} class="max-outof-range1"/>
<VNumberInput max="Infinity" v-model={value3.value} class="max-outof-range2"/>
</>
))

nextTick(() => {
// clamping the native input can be read after next tick

expect(screen.getByCSS('.max-within-range input')).toHaveValue('15')
expect(value1.value).toBe(15)

expect(screen.getByCSS('.max-outof-range1 input')).toHaveValue(Number.MAX_SAFE_INTEGER.toString())
expect(value2.value).toBe(Number.MAX_SAFE_INTEGER)

expect.element(screen.getByCSS('input')).toHaveValue('5')
expect(model.value).toBe(5)
expect(screen.getByCSS('.max-outof-range2 input')).toHaveValue(Number.MAX_SAFE_INTEGER.toString())
expect(value3.value).toBe(Number.MAX_SAFE_INTEGER)
})
})

it('should not bypass max', () => {
const model = ref(20)
render(() =>
<VNumberInput min={ 5 } max={ 15 } v-model={ model.value } />
)
it('should respect min value and fallback to min safe integer if min is out of range or cannot be parsed', () => {
const value1 = ref(2)
const value2 = ref(Number.MIN_SAFE_INTEGER - 1)
const value3 = ref(Number.MIN_SAFE_INTEGER - 1)

expect.element(screen.getByCSS('input')).toHaveValue('15')
expect(model.value).toBe(15)
render(() => (
<>
<VNumberInput min={5} v-model={value1.value} class="min-range-within-range"/>
<VNumberInput min={Number.MIN_SAFE_INTEGER - 2} v-model={value2.value} class="min-range-fallback1"/>
<VNumberInput min="-Infinity" v-model={value3.value} class="min-range-fallback2"/>
</>
))

nextTick(() => {
// clamping the native input can be read after next tick

expect(screen.getByCSS('.min-range-within-range input')).toHaveValue('5')
expect(value1.value).toBe(5)

expect(screen.getByCSS('.min-range-fallback1 input')).toHaveValue(Number.MIN_SAFE_INTEGER.toString())
expect(value2.value).toBe(Number.MIN_SAFE_INTEGER)

expect(screen.getByCSS('.min-range-fallback2 input')).toHaveValue(Number.MIN_SAFE_INTEGER.toString())
expect(value3.value).toBe(Number.MIN_SAFE_INTEGER)
})
})
})

describe('native number input quirks', () => {
it('supports decimal step', async () => {
const model = ref(0)
render(() => (
<VNumberInput
step={ 0.03 }
v-model={ model.value }
step={0.03}
v-model={model.value}
/>
))

Expand Down

0 comments on commit d68f6b0

Please sign in to comment.