vuetify
Version:
Vue Material Component Framework
419 lines (418 loc) • 15.8 kB
JavaScript
import { createVNode as _createVNode, createElementVNode as _createElementVNode, Fragment as _Fragment, mergeProps as _mergeProps } from "vue";
// Styles
import "./VNumberInput.css";
// Components
import { VBtn } from "../VBtn/index.js";
import { VDefaultsProvider } from "../VDefaultsProvider/index.js";
import { VDivider } from "../VDivider/index.js";
import { makeVTextFieldProps, VTextField } from "../VTextField/VTextField.js"; // Composables
import { formatNumber } from "./format.js";
import { useHold } from "./hold.js";
import { processGroupedInput, processPlainInput } from "./typing.js";
import { useForm } from "../../composables/form.js";
import { forwardRefs } from "../../composables/forwardRefs.js";
import { useLocale } from "../../composables/locale.js";
import { useProxiedModel } from "../../composables/proxiedModel.js"; // Utilities
import { computed, nextTick, ref, shallowRef, toRef, watch } from 'vue';
import { clamp, genericComponent, omit, propsFactory, useRender } from "../../util/index.js"; // Types
const makeVNumberInputProps = propsFactory({
controlVariant: {
type: String,
default: 'default'
},
inset: Boolean,
hideInput: Boolean,
modelValue: {
type: Number,
default: null
},
min: {
type: Number,
default: Number.MIN_SAFE_INTEGER
},
max: {
type: Number,
default: Number.MAX_SAFE_INTEGER
},
step: {
type: Number,
default: 1
},
precision: {
type: Number,
default: 0
},
minFractionDigits: {
type: Number,
default: null
},
decimalSeparator: {
type: String,
validator: v => !v || v.length === 1
},
grouping: {
type: [Boolean, String],
default: false
},
groupSeparator: {
type: String,
validator: v => !v || v.length === 1 && !/[0-9+-]/.test(v)
},
...omit(makeVTextFieldProps(), ['modelValue', 'validationValue'])
}, 'VNumberInput');
export const VNumberInput = genericComponent()({
name: 'VNumberInput',
props: {
...makeVNumberInputProps()
},
emits: {
'update:focused': val => true,
'update:modelValue': val => true
},
setup(props, {
slots
}) {
const vTextFieldRef = ref();
const {
holdStart,
holdStop
} = useHold({
toggleUpDown
});
const form = useForm(props);
const controlsDisabled = computed(() => form.isDisabled.value || form.isReadonly.value);
const isFocused = shallowRef(props.focused);
const {
current: locale,
decimalSeparator: decimalSeparatorFromLocale,
numericGroupSeparator: numericGroupSeparatorFromLocale
} = useLocale();
const decimalSeparator = computed(() => props.decimalSeparator?.[0] || decimalSeparatorFromLocale.value);
const groupSeparator = computed(() => props.groupSeparator?.[0] || numericGroupSeparatorFromLocale.value);
function toNumber(val) {
return Number(val?.replace(decimalSeparator.value, '.').replace(/[^0-9.-]/g, ''));
}
function correctPrecision(val, precision, trim = true) {
precision ??= isFocused.value && trim ? undefined : props.precision ?? undefined;
return formatNumber(val, {
locale: locale.value,
precision,
minFractionDigits: props.minFractionDigits,
useGrouping: props.grouping,
decimalSeparator: decimalSeparator.value,
groupSeparator: groupSeparator.value
});
}
const model = useProxiedModel(props, 'modelValue', null, val => val ?? null, val => val == null ? val ?? null : clamp(Number(val), props.min, props.max));
const _inputText = shallowRef(null);
const _lastParsedValue = shallowRef(null);
watch(model, val => {
if (isFocused.value && !controlsDisabled.value && toNumber(_inputText.value) === val) {
// ignore external changes while typing
// e.g. 5.01{backspace}2 » should result in 5.02
// but we emit '5' in and want to preserve '5.0'
} else if (val == null) {
_inputText.value = null;
_lastParsedValue.value = null;
} else if (!isNaN(val)) {
_inputText.value = correctPrecision(val);
_lastParsedValue.value = toNumber(_inputText.value);
}
}, {
immediate: true
});
const inputText = computed({
get: () => _inputText.value,
set(val) {
if (val === null || val === '') {
model.value = null;
_inputText.value = null;
_lastParsedValue.value = null;
return;
}
const parsedValue = toNumber(val);
if (!isNaN(parsedValue)) {
_inputText.value = val;
_lastParsedValue.value = parsedValue;
if (parsedValue <= props.max && parsedValue >= props.min) {
model.value = parsedValue;
}
}
}
});
const isOutOfRange = computed(() => {
if (_lastParsedValue.value === null) return false;
const numberFromText = toNumber(_inputText.value);
return numberFromText !== clamp(numberFromText, props.min, props.max);
});
const canIncrease = computed(() => {
if (controlsDisabled.value) return false;
if (model.value == null) return true;
return model.value + props.step <= props.max;
});
const canDecrease = computed(() => {
if (controlsDisabled.value) return false;
if (model.value == null) return true;
return model.value - props.step >= props.min;
});
const controlVariant = computed(() => {
return props.hideInput ? 'stacked' : props.controlVariant;
});
const incrementIcon = toRef(() => controlVariant.value === 'split' ? '$plus' : '$collapse');
const decrementIcon = toRef(() => controlVariant.value === 'split' ? '$minus' : '$expand');
const controlNodeSize = toRef(() => controlVariant.value === 'split' ? 'default' : 'small');
const controlNodeDefaultHeight = toRef(() => controlVariant.value === 'stacked' ? 'auto' : '100%');
const incrementSlotProps = {
props: {
onClick: onControlClick,
onPointerup: onControlMouseup,
onPointerdown: onUpControlMousedown,
onPointercancel: onControlMouseup
}
};
const decrementSlotProps = {
props: {
onClick: onControlClick,
onPointerup: onControlMouseup,
onPointerdown: onDownControlMousedown,
onPointercancel: onControlMouseup
}
};
watch(() => [locale.value, decimalSeparator.value, groupSeparator.value, props.precision, props.minFractionDigits], () => formatInputValue());
function inferPrecision(value) {
if (value == null) return 0;
const str = value.toString();
const idx = str.indexOf('.') + 1;
return idx ? str.length - idx : 0;
}
function emitChange() {
vTextFieldRef.value?.controlRef?.dispatchEvent(new Event('change', {
bubbles: true
}));
}
function toggleUpDown(increment = true) {
if (controlsDisabled.value) return;
if (increment ? !canIncrease.value : !canDecrease.value) return;
if (model.value == null) {
inputText.value = correctPrecision(clamp(0, props.min, props.max));
emitChange();
return;
}
const inferredPrecision = Math.max(inferPrecision(toNumber(inputText.value)), inferPrecision(props.step));
if (increment && canIncrease.value) {
inputText.value = correctPrecision(model.value + props.step, inferredPrecision);
emitChange();
} else if (!increment && canDecrease.value) {
inputText.value = correctPrecision(model.value - props.step, inferredPrecision);
emitChange();
}
}
function onBeforeinput(e) {
if (controlsDisabled.value) return;
const inputElement = e.target;
const result = props.grouping ? processGroupedInput(e.inputType, e.data, inputElement.value ?? '', inputElement.selectionStart ?? 0, inputElement.selectionEnd ?? 0, {
groupSeparator: groupSeparator.value,
decimalSeparator: decimalSeparator.value,
precision: props.precision,
grouping: props.grouping,
locale: locale.value
}) : processPlainInput(e.data, inputElement.value ?? '', inputElement.selectionStart ?? 0, inputElement.selectionEnd ?? 0, {
decimalSeparator: decimalSeparator.value,
precision: props.precision
});
if (result === null) return;
e.preventDefault();
inputElement.value = result.text;
inputElement.setSelectionRange(result.cursor, result.cursor);
nextTick(() => inputText.value = result.text);
}
async function onKeydown(e) {
if (['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab'].includes(e.key) || e.ctrlKey) return;
if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
e.preventDefault();
e.stopPropagation();
clampModel();
// _model is controlled, so need to wait until props['modelValue'] is updated
await nextTick();
if (e.key === 'ArrowDown') {
toggleUpDown(false);
} else {
toggleUpDown();
}
}
}
function onControlClick(e) {
e.stopPropagation();
}
function onControlMouseup(e) {
const el = e.currentTarget;
el?.releasePointerCapture(e.pointerId);
e.preventDefault();
holdStop();
}
function onUpControlMousedown(e) {
const el = e.currentTarget;
el?.setPointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
holdStart('up');
}
function onDownControlMousedown(e) {
const el = e.currentTarget;
el?.setPointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
holdStart('down');
}
function clampModel() {
if (controlsDisabled.value) return;
if (!vTextFieldRef.value) return;
const actualText = vTextFieldRef.value.value;
const parsedValue = toNumber(actualText);
if (actualText && !isNaN(parsedValue)) {
inputText.value = correctPrecision(clamp(parsedValue, props.min, props.max));
} else {
inputText.value = null;
}
}
function formatInputValue() {
if (controlsDisabled.value) return;
inputText.value = model.value !== null && !isNaN(model.value) ? correctPrecision(model.value, props.precision, false) : null;
}
function trimDecimalZeros() {
if (controlsDisabled.value) return;
if (model.value === null || isNaN(model.value)) {
inputText.value = null;
return;
}
inputText.value = correctPrecision(model.value);
}
function onFocus() {
trimDecimalZeros();
}
function onBlur() {
clampModel();
}
useRender(() => {
const {
modelValue: _,
type,
...textFieldProps
} = VTextField.filterProps(props);
function incrementControlNode() {
return !slots.increment ? _createVNode(VBtn, {
"aria-hidden": "true",
"data-testid": "increment",
"disabled": !canIncrease.value,
"height": controlNodeDefaultHeight.value,
"icon": incrementIcon.value,
"key": "increment-btn",
"onClick": onControlClick,
"onPointerdown": onUpControlMousedown,
"onPointerup": onControlMouseup,
"onPointercancel": onControlMouseup,
"size": controlNodeSize.value,
"variant": "text",
"tabindex": "-1"
}, null) : _createVNode(VDefaultsProvider, {
"key": "increment-defaults",
"defaults": {
VBtn: {
disabled: !canIncrease.value,
height: controlNodeDefaultHeight.value,
size: controlNodeSize.value,
icon: incrementIcon.value,
variant: 'text'
}
}
}, {
default: () => [slots.increment(incrementSlotProps)]
});
}
function decrementControlNode() {
return !slots.decrement ? _createVNode(VBtn, {
"aria-hidden": "true",
"data-testid": "decrement",
"disabled": !canDecrease.value,
"height": controlNodeDefaultHeight.value,
"icon": decrementIcon.value,
"key": "decrement-btn",
"onClick": onControlClick,
"onPointerdown": onDownControlMousedown,
"onPointerup": onControlMouseup,
"onPointercancel": onControlMouseup,
"size": controlNodeSize.value,
"variant": "text",
"tabindex": "-1"
}, null) : _createVNode(VDefaultsProvider, {
"key": "decrement-defaults",
"defaults": {
VBtn: {
disabled: !canDecrease.value,
height: controlNodeDefaultHeight.value,
size: controlNodeSize.value,
icon: decrementIcon.value,
variant: 'text'
}
}
}, {
default: () => [slots.decrement(decrementSlotProps)]
});
}
function controlNode() {
return _createElementVNode("div", {
"class": "v-number-input__control"
}, [decrementControlNode(), _createVNode(VDivider, {
"vertical": controlVariant.value !== 'stacked'
}, null), incrementControlNode()]);
}
function dividerNode() {
return !props.hideInput && !props.inset ? _createVNode(VDivider, {
"vertical": true
}, null) : undefined;
}
const appendInnerControl = controlVariant.value === 'split' ? _createElementVNode("div", {
"class": "v-number-input__control"
}, [_createVNode(VDivider, {
"vertical": true
}, null), incrementControlNode()]) : props.reverse || controlVariant.value === 'hidden' ? undefined : _createElementVNode(_Fragment, null, [dividerNode(), controlNode()]);
const hasAppendInner = slots['append-inner'] || appendInnerControl;
const prependInnerControl = controlVariant.value === 'split' ? _createElementVNode("div", {
"class": "v-number-input__control"
}, [decrementControlNode(), _createVNode(VDivider, {
"vertical": true
}, null)]) : props.reverse && controlVariant.value !== 'hidden' ? _createElementVNode(_Fragment, null, [controlNode(), dividerNode()]) : undefined;
const hasPrependInner = slots['prepend-inner'] || prependInnerControl;
return _createVNode(VTextField, _mergeProps({
"ref": vTextFieldRef
}, textFieldProps, {
"modelValue": inputText.value,
"onUpdate:modelValue": $event => inputText.value = $event,
"focused": isFocused.value,
"onUpdate:focused": $event => isFocused.value = $event,
"validationValue": model.value,
"error": props.error || isOutOfRange.value || undefined,
"onBeforeinput": onBeforeinput,
"onFocus": onFocus,
"onBlur": onBlur,
"onKeydown": onKeydown,
"class": ['v-number-input', {
'v-number-input--default': controlVariant.value === 'default',
'v-number-input--hide-input': props.hideInput,
'v-number-input--inset': props.inset,
'v-number-input--reverse': props.reverse,
'v-number-input--split': controlVariant.value === 'split',
'v-number-input--stacked': controlVariant.value === 'stacked'
}, props.class],
"style": props.style,
"inputmode": "decimal"
}), {
...slots,
'append-inner': hasAppendInner ? (...args) => _createElementVNode(_Fragment, null, [slots['append-inner']?.(...args), appendInnerControl]) : undefined,
'prepend-inner': hasPrependInner ? (...args) => _createElementVNode(_Fragment, null, [prependInnerControl, slots['prepend-inner']?.(...args)]) : undefined
});
});
return forwardRefs({}, vTextFieldRef);
}
});
//# sourceMappingURL=VNumberInput.js.map