diff --git a/packages/docs/src/examples/v-number-input/prop-precision.vue b/packages/docs/src/examples/v-number-input/prop-precision.vue
new file mode 100644
index 00000000000..f616f8fb992
--- /dev/null
+++ b/packages/docs/src/examples/v-number-input/prop-precision.vue
@@ -0,0 +1,40 @@
+
+
+
+
+ (default precision="0")
+
+ value: {{ example1 }}
+
+
+ (precision="2")
+
+ value: {{ example2 }}
+
+
+ (precision="5")
+
+ value: {{ example3 }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/docs/src/pages/en/components/number-inputs.md b/packages/docs/src/pages/en/components/number-inputs.md
index 8d18a3e09ff..e9821b2e846 100644
--- a/packages/docs/src/pages/en/components/number-inputs.md
+++ b/packages/docs/src/pages/en/components/number-inputs.md
@@ -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 ``, it defines the incremental steps for adjusting the numeric value.
+
+#### 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).
+
+
diff --git a/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx b/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx
index f3ee59cbe3b..bd8076bdf23 100644
--- a/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx
+++ b/packages/vuetify/src/labs/VNumberInput/VNumberInput.tsx
@@ -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'
@@ -39,7 +39,7 @@ const makeVNumberInputProps = propsFactory({
inset: Boolean,
hideInput: Boolean,
modelValue: {
- type: Number as PropType,
+ type: Number as PropType,
default: null,
},
min: {
@@ -54,6 +54,10 @@ const makeVNumberInputProps = propsFactory({
type: Number,
default: 1,
},
+ precision: {
+ type: Number,
+ default: 0,
+ },
...omit(makeVTextFieldProps({}), ['appendInnerIcon', 'modelValue', 'prependInnerIcon']),
}, 'VNumberInput')
@@ -70,35 +74,57 @@ export const VNumberInput = genericComponent()({
},
setup (props, { slots }) {
- const _model = useProxiedModel(props, 'modelValue')
+ const vTextFieldRef = ref()
+ const _inputText = ref(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({
+ 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()
-
- 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
@@ -118,27 +144,25 @@ export const VNumberInput = genericComponent()({
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)
}
}
@@ -167,6 +191,14 @@ export const VNumberInput = genericComponent()({
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) {
@@ -193,15 +225,44 @@ export const VNumberInput = genericComponent()({
}
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)
@@ -320,9 +381,10 @@ export const VNumberInput = genericComponent()({
return (
{
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(() => )
await userEvent.click(element)
await userEvent.keyboard(typing)
expect(screen.getByCSS('input')).toHaveValue(expected)
@@ -28,7 +28,6 @@ describe('VNumberInput', () => {
))
@@ -134,6 +133,7 @@ describe('VNumberInput', () => {
{
{
render(() => (
))