Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(VNumberInput): strict precision #20252

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/docs/src/examples/v-number-input/prop-precision.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<template>
<v-container>
<v-row>
<v-col>
<h5>(default precision="0")</h5>
<v-number-input v-model="example1" hide-details="auto"></v-number-input>
<code class="d-block pt-3">value: {{ example1 }}</code>
</v-col>
<v-col>
<h5>(precision="2")</h5>
<v-number-input v-model="example2" :precision="2" hide-details="auto"></v-number-input>
<code class="d-block pt-3">value: {{ example2 }}</code>
</v-col>
<v-col>
<h5>(precision="5")</h5>
<v-number-input v-model="example3" :precision="5" hide-details="auto"></v-number-input>
<code class="d-block pt-3">value: {{ example3 }}</code>
</v-col>
<v-col> </v-col>
</v-row>
</v-container>
</template>

<script setup>
import { ref } from 'vue'

const example1 = ref(123)
const example2 = ref(25.5)
const example3 = ref(0.052)
</script>

<script>
export default {
data: () => ({
example1: 123,
example2: 25.5,
example3: 0.052,
}),
}
</script>
6 changes: 6 additions & 0 deletions packages/docs/src/pages/en/components/number-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,9 @@ The `min` and `max` props specify the minimum and maximum values accepted by v-n
The `step` prop behaves the same as the `step` attribute in the `<input type="number">`, it defines the incremental steps for adjusting the numeric value.

<ExamplesExample file="v-number-input/prop-step" />

#### Precision

The `precision` prop enforces strict precision. It is expected to be an integer value in range between `0` and `15`. While it won't prevent user from typing or pasting the invalid value, additional validation rule helps detect the incorrect value and the field auto-corrects itself once user leaves the field (on blur).

<ExamplesExample file="v-number-input/prop-precision" />
136 changes: 99 additions & 37 deletions packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { forwardRefs } from '@/composables/forwardRefs'
import { useProxiedModel } from '@/composables/proxiedModel'

// Utilities
import { computed, nextTick, onMounted, ref } from 'vue'
import { clamp, genericComponent, getDecimals, omit, propsFactory, useRender } from '@/util'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { clamp, genericComponent, omit, propsFactory, useRender } from '@/util'

// Types
import type { PropType } from 'vue'
Expand All @@ -39,7 +39,7 @@ const makeVNumberInputProps = propsFactory({
inset: Boolean,
hideInput: Boolean,
modelValue: {
type: Number as PropType<Number | null>,
type: Number as PropType<number | null>,
default: null,
},
min: {
Expand All @@ -54,6 +54,10 @@ const makeVNumberInputProps = propsFactory({
type: Number,
default: 1,
},
precision: {
type: Number,
default: 0,
},

...omit(makeVTextFieldProps({}), ['appendInnerIcon', 'modelValue', 'prependInnerIcon']),
}, 'VNumberInput')
Expand All @@ -70,35 +74,57 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
},

setup (props, { slots }) {
const _model = useProxiedModel(props, 'modelValue')
const vTextFieldRef = ref<VTextField | undefined>()
const _inputText = ref<string | null>(null)

const model = computed({
get: () => _model.value,
// model.value could be empty string from VTextField
// but _model.value should be eventually kept in type Number | null
set (val: Number | null | string) {
const form = useForm(props)
const controlsDisabled = computed(() => (
form.isDisabled.value || form.isReadonly.value
))

const isFocused = ref(false)
function correctPrecision (val: number) {
return isFocused.value
? Number(val.toFixed(props.precision)).toString() // trim zeros
: val.toFixed(props.precision)
}

const model = useProxiedModel(props, 'modelValue', null,
val => {
if (isFocused.value && !controlsDisabled.value) {
// ignore external changes
} else if (val == null || controlsDisabled.value) {
_inputText.value = val && !isNaN(val) ? String(val) : null
} else if (!isNaN(val)) {
_inputText.value = correctPrecision(val)
}
return val ?? null
},
val => val == null
? val ?? null
: clamp(+val, props.min, props.max)
)

watch(model, () => {
// ensure proxiedModel transformIn is being called when readonly
}, { immediate: true })

const inputText = computed<string | null>({
get: () => _inputText.value,
set (val) {
if (val === null || val === '') {
_model.value = null
model.value = null
_inputText.value = null
return
}

const value = Number(val)
if (!isNaN(value) && value <= props.max && value >= props.min) {
_model.value = value
if (!isNaN(+val) && +val <= props.max && +val >= props.min) {
model.value = val as any
_inputText.value = val
}
},
})

const vTextFieldRef = ref<VTextField | undefined>()

const stepDecimals = computed(() => getDecimals(props.step))
const modelDecimals = computed(() => typeof model.value === 'number' ? getDecimals(model.value) : 0)

const form = useForm(props)
const controlsDisabled = computed(() => (
form.isDisabled.value || form.isReadonly.value
))

const canIncrease = computed(() => {
if (controlsDisabled.value) return false
return (model.value ?? 0) as number + props.step <= props.max
Expand All @@ -118,27 +144,25 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
const controlNodeDefaultHeight = computed(() => controlVariant.value === 'stacked' ? 'auto' : '100%')

const incrementSlotProps = computed(() => ({ click: onClickUp }))

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

watch(() => props.precision, () => formatInputValue())

onMounted(() => {
if (!controlsDisabled.value) {
clampModel()
}
clampModel()
})

function toggleUpDown (increment = true) {
if (controlsDisabled.value) return
if (model.value == null) {
model.value = clamp(0, props.min, props.max)
inputText.value = correctPrecision(clamp(0, props.min, props.max))
return
}

const decimals = Math.max(modelDecimals.value, stepDecimals.value)
if (increment) {
if (canIncrease.value) model.value = +((((model.value as number) + props.step).toFixed(decimals)))
if (canIncrease.value) inputText.value = correctPrecision(model.value + props.step)
} else {
if (canDecrease.value) model.value = +((((model.value as number) - props.step).toFixed(decimals)))
if (canDecrease.value) inputText.value = correctPrecision(model.value - props.step)
}
}

Expand Down Expand Up @@ -167,6 +191,14 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
if (!/^-?(\d+(\.\d*)?|(\.\d+)|\d*|\.)$/.test(potentialNewInputVal)) {
e.preventDefault()
}
// Ignore decimal digits above precision limit
if (potentialNewInputVal.split('.')[1]?.length > props.precision) {
e.preventDefault()
}
// Ignore decimal separator when precision = 0
if (props.precision === 0 && potentialNewInputVal.includes('.')) {
e.preventDefault()
}
}

async function onKeydown (e: KeyboardEvent) {
Expand All @@ -193,15 +225,44 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
}

function clampModel () {
if (controlsDisabled.value) return
if (!vTextFieldRef.value) return
const inputText = vTextFieldRef.value.value
if (inputText && !isNaN(+inputText)) {
model.value = clamp(+(inputText), props.min, props.max)
const actualText = vTextFieldRef.value.value
if (actualText && !isNaN(+actualText)) {
inputText.value = correctPrecision(clamp(+actualText, props.min, props.max))
} else {
model.value = null
inputText.value = null
}
}

function formatInputValue () {
if (controlsDisabled.value) return
if (model.value === null || isNaN(model.value)) {
inputText.value = null
return
}
inputText.value = model.value.toFixed(props.precision)
}

function trimDecimalZeros () {
if (controlsDisabled.value) return
if (model.value === null || isNaN(model.value)) {
inputText.value = null
return
}
inputText.value = model.value.toString()
}

function onFocus () {
isFocused.value = true
trimDecimalZeros()
}

function onBlur () {
isFocused.value = false
clampModel()
}

useRender(() => {
const { modelValue: _, ...textFieldProps } = VTextField.filterProps(props)

Expand Down Expand Up @@ -320,9 +381,10 @@ export const VNumberInput = genericComponent<VNumberInputSlots>()({
return (
<VTextField
ref={ vTextFieldRef }
v-model={ model.value }
v-model={ inputText.value }
onBeforeinput={ onBeforeinput }
onChange={ clampModel }
onFocus={ onFocus }
onBlur={ onBlur }
onKeydown={ onKeydown }
class={[
'v-number-input',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import { ref } from 'vue'

describe('VNumberInput', () => {
it.each([
{ typing: '---', expected: '-' }, // "-" is only allowed once
{ typing: '1-', expected: '1' }, // "-" is only at the start
{ typing: '.', expected: '.' }, // "." is allowed at the start
{ 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
])('prevents NaN from arbitrary input', async ({ typing, expected }) => {
const { element } = render(VNumberInput)
{ precision: 0, typing: '---', expected: '-' }, // "-" is only allowed once
{ precision: 0, typing: '1-', expected: '1' }, // "-" is only at the start
{ precision: 1, typing: '.', expected: '.' }, // "." is allowed at the start
{ precision: 1, typing: '..', expected: '.' }, // "." is only allowed once
{ precision: 1, typing: '1...0', expected: '1.0' }, // "." is only allowed once
{ precision: 4, typing: '123.45.67', expected: '123.4567' }, // "." is only allowed once
{ precision: 1, typing: 'ab-c8+.iop9', expected: '-8.9' }, // Only numbers, "-", "." are allowed to type in
])('prevents NaN from arbitrary input', async ({ precision, typing, expected }) => {
const { element } = render(() => <VNumberInput precision={ precision } />)
await userEvent.click(element)
await userEvent.keyboard(typing)
expect(screen.getByCSS('input')).toHaveValue(expected)
Expand All @@ -28,7 +28,6 @@ describe('VNumberInput', () => {
<VNumberInput
clearable
v-model={ model.value }
readonly
/>
))

Expand Down Expand Up @@ -134,13 +133,15 @@ describe('VNumberInput', () => {
<VNumberInput
class="disabled-input-1"
v-model={ value3.value }
precision={ 1 }
min={ 0 }
max={ 10 }
disabled
/>
<VNumberInput
class="disabled-input-2"
v-model={ value4.value }
precision={ 1 }
min={ 0 }
max={ 10 }
disabled
Expand Down Expand Up @@ -181,6 +182,7 @@ describe('VNumberInput', () => {
render(() => (
<VNumberInput
step={ 0.03 }
precision={ 2 }
v-model={ model.value }
/>
))
Expand Down
Loading