VNumberInput.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { mergeProps as _mergeProps, Fragment as _Fragment, createVNode as _createVNode } from "vue";
  2. // Styles
  3. import "./VNumberInput.css";
  4. // Components
  5. import { VBtn } from "../../components/VBtn/index.mjs";
  6. import { VDefaultsProvider } from "../../components/VDefaultsProvider/index.mjs";
  7. import { VDivider } from "../../components/VDivider/index.mjs";
  8. import { makeVTextFieldProps, VTextField } from "../../components/VTextField/VTextField.mjs"; // Composables
  9. import { useForm } from "../../composables/form.mjs";
  10. import { forwardRefs } from "../../composables/forwardRefs.mjs";
  11. import { useProxiedModel } from "../../composables/proxiedModel.mjs"; // Utilities
  12. import { computed, nextTick, onMounted, ref } from 'vue';
  13. import { clamp, genericComponent, getDecimals, omit, propsFactory, useRender } from "../../util/index.mjs"; // Types
  14. const makeVNumberInputProps = propsFactory({
  15. controlVariant: {
  16. type: String,
  17. default: 'default'
  18. },
  19. inset: Boolean,
  20. hideInput: Boolean,
  21. modelValue: {
  22. type: Number,
  23. default: null
  24. },
  25. min: {
  26. type: Number,
  27. default: Number.MIN_SAFE_INTEGER
  28. },
  29. max: {
  30. type: Number,
  31. default: Number.MAX_SAFE_INTEGER
  32. },
  33. step: {
  34. type: Number,
  35. default: 1
  36. },
  37. ...omit(makeVTextFieldProps({}), ['appendInnerIcon', 'modelValue', 'prependInnerIcon'])
  38. }, 'VNumberInput');
  39. export const VNumberInput = genericComponent()({
  40. name: 'VNumberInput',
  41. props: {
  42. ...makeVNumberInputProps()
  43. },
  44. emits: {
  45. 'update:modelValue': val => true
  46. },
  47. setup(props, _ref) {
  48. let {
  49. slots
  50. } = _ref;
  51. const _model = useProxiedModel(props, 'modelValue');
  52. const model = computed({
  53. get: () => _model.value,
  54. // model.value could be empty string from VTextField
  55. // but _model.value should be eventually kept in type Number | null
  56. set(val) {
  57. if (val === null || val === '') {
  58. _model.value = null;
  59. return;
  60. }
  61. const value = Number(val);
  62. if (!isNaN(value) && value <= props.max && value >= props.min) {
  63. _model.value = value;
  64. }
  65. }
  66. });
  67. const vTextFieldRef = ref();
  68. const stepDecimals = computed(() => getDecimals(props.step));
  69. const modelDecimals = computed(() => typeof model.value === 'number' ? getDecimals(model.value) : 0);
  70. const form = useForm(props);
  71. const controlsDisabled = computed(() => form.isDisabled.value || form.isReadonly.value);
  72. const canIncrease = computed(() => {
  73. if (controlsDisabled.value) return false;
  74. return (model.value ?? 0) + props.step <= props.max;
  75. });
  76. const canDecrease = computed(() => {
  77. if (controlsDisabled.value) return false;
  78. return (model.value ?? 0) - props.step >= props.min;
  79. });
  80. const controlVariant = computed(() => {
  81. return props.hideInput ? 'stacked' : props.controlVariant;
  82. });
  83. const incrementIcon = computed(() => controlVariant.value === 'split' ? '$plus' : '$collapse');
  84. const decrementIcon = computed(() => controlVariant.value === 'split' ? '$minus' : '$expand');
  85. const controlNodeSize = computed(() => controlVariant.value === 'split' ? 'default' : 'small');
  86. const controlNodeDefaultHeight = computed(() => controlVariant.value === 'stacked' ? 'auto' : '100%');
  87. const incrementSlotProps = computed(() => ({
  88. click: onClickUp
  89. }));
  90. const decrementSlotProps = computed(() => ({
  91. click: onClickDown
  92. }));
  93. onMounted(() => {
  94. if (!controlsDisabled.value) {
  95. clampModel();
  96. }
  97. });
  98. function toggleUpDown() {
  99. let increment = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
  100. if (controlsDisabled.value) return;
  101. if (model.value == null) {
  102. model.value = clamp(0, props.min, props.max);
  103. return;
  104. }
  105. const decimals = Math.max(modelDecimals.value, stepDecimals.value);
  106. if (increment) {
  107. if (canIncrease.value) model.value = +(model.value + props.step).toFixed(decimals);
  108. } else {
  109. if (canDecrease.value) model.value = +(model.value - props.step).toFixed(decimals);
  110. }
  111. }
  112. function onClickUp(e) {
  113. e.stopPropagation();
  114. toggleUpDown();
  115. }
  116. function onClickDown(e) {
  117. e.stopPropagation();
  118. toggleUpDown(false);
  119. }
  120. function onBeforeinput(e) {
  121. if (!e.data) return;
  122. const existingTxt = e.target?.value;
  123. const selectionStart = e.target?.selectionStart;
  124. const selectionEnd = e.target?.selectionEnd;
  125. const potentialNewInputVal = existingTxt ? existingTxt.slice(0, selectionStart) + e.data + existingTxt.slice(selectionEnd) : e.data;
  126. // Only numbers, "-", "." are allowed
  127. // AND "-", "." are allowed only once
  128. // AND "-" is only allowed at the start
  129. if (!/^-?(\d+(\.\d*)?|(\.\d+)|\d*|\.)$/.test(potentialNewInputVal)) {
  130. e.preventDefault();
  131. }
  132. }
  133. async function onKeydown(e) {
  134. if (['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab'].includes(e.key) || e.ctrlKey) return;
  135. if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
  136. e.preventDefault();
  137. clampModel();
  138. // _model is controlled, so need to wait until props['modelValue'] is updated
  139. await nextTick();
  140. if (e.key === 'ArrowDown') {
  141. toggleUpDown(false);
  142. } else {
  143. toggleUpDown();
  144. }
  145. }
  146. }
  147. function onControlMousedown(e) {
  148. e.stopPropagation();
  149. }
  150. function clampModel() {
  151. if (!vTextFieldRef.value) return;
  152. const inputText = vTextFieldRef.value.value;
  153. if (inputText && !isNaN(+inputText)) {
  154. model.value = clamp(+inputText, props.min, props.max);
  155. } else {
  156. model.value = null;
  157. }
  158. }
  159. useRender(() => {
  160. const {
  161. modelValue: _,
  162. ...textFieldProps
  163. } = VTextField.filterProps(props);
  164. function incrementControlNode() {
  165. return !slots.increment ? _createVNode(VBtn, {
  166. "disabled": !canIncrease.value,
  167. "flat": true,
  168. "key": "increment-btn",
  169. "height": controlNodeDefaultHeight.value,
  170. "data-testid": "increment",
  171. "aria-hidden": "true",
  172. "icon": incrementIcon.value,
  173. "onClick": onClickUp,
  174. "onMousedown": onControlMousedown,
  175. "size": controlNodeSize.value,
  176. "tabindex": "-1"
  177. }, null) : _createVNode(VDefaultsProvider, {
  178. "key": "increment-defaults",
  179. "defaults": {
  180. VBtn: {
  181. disabled: !canIncrease.value,
  182. flat: true,
  183. height: controlNodeDefaultHeight.value,
  184. size: controlNodeSize.value,
  185. icon: incrementIcon.value
  186. }
  187. }
  188. }, {
  189. default: () => [slots.increment(incrementSlotProps.value)]
  190. });
  191. }
  192. function decrementControlNode() {
  193. return !slots.decrement ? _createVNode(VBtn, {
  194. "disabled": !canDecrease.value,
  195. "flat": true,
  196. "key": "decrement-btn",
  197. "height": controlNodeDefaultHeight.value,
  198. "data-testid": "decrement",
  199. "aria-hidden": "true",
  200. "icon": decrementIcon.value,
  201. "size": controlNodeSize.value,
  202. "tabindex": "-1",
  203. "onClick": onClickDown,
  204. "onMousedown": onControlMousedown
  205. }, null) : _createVNode(VDefaultsProvider, {
  206. "key": "decrement-defaults",
  207. "defaults": {
  208. VBtn: {
  209. disabled: !canDecrease.value,
  210. flat: true,
  211. height: controlNodeDefaultHeight.value,
  212. size: controlNodeSize.value,
  213. icon: decrementIcon.value
  214. }
  215. }
  216. }, {
  217. default: () => [slots.decrement(decrementSlotProps.value)]
  218. });
  219. }
  220. function controlNode() {
  221. return _createVNode("div", {
  222. "class": "v-number-input__control"
  223. }, [decrementControlNode(), _createVNode(VDivider, {
  224. "vertical": controlVariant.value !== 'stacked'
  225. }, null), incrementControlNode()]);
  226. }
  227. function dividerNode() {
  228. return !props.hideInput && !props.inset ? _createVNode(VDivider, {
  229. "vertical": true
  230. }, null) : undefined;
  231. }
  232. const appendInnerControl = controlVariant.value === 'split' ? _createVNode("div", {
  233. "class": "v-number-input__control"
  234. }, [_createVNode(VDivider, {
  235. "vertical": true
  236. }, null), incrementControlNode()]) : props.reverse ? undefined : _createVNode(_Fragment, null, [dividerNode(), controlNode()]);
  237. const hasAppendInner = slots['append-inner'] || appendInnerControl;
  238. const prependInnerControl = controlVariant.value === 'split' ? _createVNode("div", {
  239. "class": "v-number-input__control"
  240. }, [decrementControlNode(), _createVNode(VDivider, {
  241. "vertical": true
  242. }, null)]) : props.reverse ? _createVNode(_Fragment, null, [controlNode(), dividerNode()]) : undefined;
  243. const hasPrependInner = slots['prepend-inner'] || prependInnerControl;
  244. return _createVNode(VTextField, _mergeProps({
  245. "ref": vTextFieldRef,
  246. "modelValue": model.value,
  247. "onUpdate:modelValue": $event => model.value = $event,
  248. "onBeforeinput": onBeforeinput,
  249. "onChange": clampModel,
  250. "onKeydown": onKeydown,
  251. "class": ['v-number-input', {
  252. 'v-number-input--default': controlVariant.value === 'default',
  253. 'v-number-input--hide-input': props.hideInput,
  254. 'v-number-input--inset': props.inset,
  255. 'v-number-input--reverse': props.reverse,
  256. 'v-number-input--split': controlVariant.value === 'split',
  257. 'v-number-input--stacked': controlVariant.value === 'stacked'
  258. }, props.class]
  259. }, textFieldProps, {
  260. "style": props.style,
  261. "inputmode": "decimal"
  262. }), {
  263. ...slots,
  264. 'append-inner': hasAppendInner ? function () {
  265. for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
  266. args[_key] = arguments[_key];
  267. }
  268. return _createVNode(_Fragment, null, [slots['append-inner']?.(...args), appendInnerControl]);
  269. } : undefined,
  270. 'prepend-inner': hasPrependInner ? function () {
  271. for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
  272. args[_key2] = arguments[_key2];
  273. }
  274. return _createVNode(_Fragment, null, [prependInnerControl, slots['prepend-inner']?.(...args)]);
  275. } : undefined
  276. });
  277. });
  278. return forwardRefs({}, vTextFieldRef);
  279. }
  280. });
  281. //# sourceMappingURL=VNumberInput.mjs.map