NumberInput.vue
1 <template> 2 <div class="number-input"> 3 <button 4 class="minus" 5 @mousedown="startDecrement" 6 @mouseup="stopRepeat" 7 @mouseleave="stopRepeat" 8 :disabled="disabled || modelValue <= min"> 9 <svg 10 width="16" 11 height="16" 12 viewBox="0 0 16 16" 13 fill="none" 14 xmlns="http://www.w3.org/2000/svg"> 15 <g> 16 <path 17 d="M0 0L9.33333 0" 18 fill="none" 19 stroke-width="2" 20 stroke="#191919" 21 stroke-linecap="round" 22 stroke-linejoin="round" 23 transform="translate(3.333 8)" /> 24 </g> 25 </svg> 26 </button> 27 <input 28 type="text" 29 class="number-field" 30 :value="modelValue" 31 @input="handleInput" 32 @blur="validateInput" 33 @keydown.enter.prevent="validateInput" 34 :tabindex="disabled ? -1 : 0" 35 :style="{ width: inputWidth + 'px' }" /> 36 <button 37 class="plus" 38 @mousedown="startIncrement" 39 @mouseup="stopRepeat" 40 @mouseleave="stopRepeat" 41 :disabled="disabled || modelValue >= max"> 42 <svg 43 width="15.333" 44 height="15.333" 45 viewBox="0 0 15.333 15.333" 46 fill="none" 47 xmlns="http://www.w3.org/2000/svg"> 48 <g transform="translate(-0 0)"> 49 <path 50 d="M0 4.66667L9.33333 4.66667M4.66667 0L4.66667 9.33333" 51 fill="none" 52 stroke-width="2" 53 stroke="#191919" 54 stroke-linecap="round" 55 stroke-linejoin="round" 56 transform="translate(3 3)" /> 57 </g> 58 </svg> 59 </button> 60 </div> 61 </template> 62 63 <script setup> 64 import { computed, ref } from "vue"; 65 66 const props = defineProps({ 67 modelValue: Number, 68 disabled: Boolean, 69 min: Number, 70 max: Number, 71 }); 72 73 const emit = defineEmits(["update:modelValue"]); 74 const repeatInterval = ref(null); 75 const repeatDelay = 100; 76 const repeatTimeout = 300; 77 78 const inputWidth = computed(() => { 79 const charWidth = 7.2; 80 const padding = 4; 81 const minWidth = 0; 82 const width = String(props.modelValue).length * charWidth + padding; 83 return Math.max(width, minWidth); 84 }); 85 86 const updateValue = (value) => { 87 const boundedValue = Math.max(props.min, Math.min(props.max, value)); 88 emit("update:modelValue", boundedValue); 89 }; 90 91 const startIncrement = () => { 92 updateValue(Number(props.modelValue) + 1); 93 repeatInterval.value = setTimeout(() => { 94 repeatInterval.value = setInterval(() => { 95 updateValue(Number(props.modelValue) + 1); 96 }, repeatDelay); 97 }, repeatTimeout); 98 }; 99 100 const startDecrement = () => { 101 updateValue(Number(props.modelValue) - 1); 102 repeatInterval.value = setTimeout(() => { 103 repeatInterval.value = setInterval(() => { 104 updateValue(Number(props.modelValue) - 1); 105 }, repeatDelay); 106 }, repeatTimeout); 107 }; 108 109 const stopRepeat = () => { 110 if (repeatInterval.value) { 111 clearInterval(repeatInterval.value); 112 repeatInterval.value = null; 113 } 114 }; 115 116 const handleInput = (e) => { 117 const value = e.target.value.trim(); 118 if (value === "") { 119 e.target.value = String(props.min); 120 updateValue(props.min); 121 return; 122 } 123 if (/^\d*$/.test(value)) { 124 updateValue(Number(value)); 125 } else { 126 e.target.value = props.modelValue; 127 } 128 }; 129 130 const validateInput = (e) => { 131 e.target.value = props.modelValue; 132 }; 133 </script> 134 135 <style scoped lang="scss"> 136 .number-input { 137 display: flex; 138 align-items: center; 139 height: 36px; 140 border: 1px solid var(--border); 141 border-radius: 10px; 142 overflow: hidden; 143 gap: 12px; 144 width: min-content; 145 146 .number-field { 147 border: none; 148 background: none; 149 text-align: center; 150 color: var(--text); 151 font-family: inherit; 152 font-size: 14px; 153 padding: 0; 154 flex-grow: 0; 155 outline: none; 156 cursor: text; 157 } 158 159 button { 160 border-radius: 9px; 161 aspect-ratio: 1; 162 width: 34px; 163 height: 34px; 164 border: none; 165 background: none; 166 cursor: pointer; 167 font-family: inherit; 168 display: flex; 169 align-items: center; 170 justify-content: center; 171 172 &:disabled { 173 opacity: 0.6; 174 cursor: not-allowed; 175 } 176 } 177 178 .minus { 179 background-color: var(--text-secondary); 180 } 181 182 .plus { 183 background-color: var(--text); 184 } 185 } 186 </style>