From d68f6b0728c47c4e80cfa723b186652e38203c4a Mon Sep 17 00:00:00 2001 From: "Yuval.D" Date: Wed, 18 Dec 2024 14:00:08 +0200 Subject: [PATCH] feat(VNumberInput): add parsing and fallback for min/max values 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 #20788 --- .../vuetify/playgrounds/Playground.number.vue | 73 ++++++++++++ .../src/labs/VNumberInput/VNumberInput.tsx | 17 +-- .../__tests__/VNumberInput.spec.browser.tsx | 111 ++++++++++++------ 3 files changed, 157 insertions(+), 44 deletions(-) create mode 100644 packages/vuetify/playgrounds/Playground.number.vue diff --git a/packages/vuetify/playgrounds/Playground.number.vue b/packages/vuetify/playgrounds/Playground.number.vue new file mode 100644 index 00000000000..18b3f5438d3 --- /dev/null +++ b/packages/vuetify/playgrounds/Playground.number.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx b/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx index f3ee59cbe3b..31a209a1768 100644 --- a/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx +++ b/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx @@ -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: { @@ -72,6 +72,9 @@ export const VNumberInput = genericComponent()({ 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 @@ -83,7 +86,7 @@ export const VNumberInput = genericComponent()({ } 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 } }, @@ -101,11 +104,11 @@ export const VNumberInput = genericComponent()({ 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(() => { @@ -130,7 +133,7 @@ export const VNumberInput = genericComponent()({ 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 } @@ -196,7 +199,7 @@ export const VNumberInput = genericComponent()({ 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 } diff --git a/packages/vuetify/src/labs/VNumberInput/__tests__/VNumberInput.spec.browser.tsx b/packages/vuetify/src/labs/VNumberInput/__tests__/VNumberInput.spec.browser.tsx index 34b774a003c..81468566f7b 100644 --- a/packages/vuetify/src/labs/VNumberInput/__tests__/VNumberInput.spec.browser.tsx +++ b/packages/vuetify/src/labs/VNumberInput/__tests__/VNumberInput.spec.browser.tsx @@ -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([ @@ -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) @@ -27,7 +28,7 @@ describe('VNumberInput', () => { render(() => ( )) @@ -41,9 +42,9 @@ describe('VNumberInput', () => { const model = ref(null) const { element } = render(() => ( )) @@ -69,7 +70,7 @@ describe('VNumberInput', () => { const model = ref(1) const { element } = render(() => ( - + )) await userEvent.click(screen.getByTestId('increment')) @@ -91,7 +92,7 @@ describe('VNumberInput', () => { const { element } = render(() => ( - + )) @@ -119,30 +120,30 @@ describe('VNumberInput', () => { <> @@ -155,33 +156,69 @@ describe('VNumberInput', () => { }) }) - describe('native number input quirks', () => { - it('should not bypass min', async () => { - const model = ref(1) - render(() => - - ) + 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(() => ( + <> + + + + + )) + + 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(() => - - ) + 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(() => ( + <> + + + + + )) + + 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(() => ( ))