UNPKG

vuetify

Version:

Vue Material Component Framework

419 lines (418 loc) 15.8 kB
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