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 @@ + + + + + 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(() => ( ))