/ app / components / Ui / NumberInput.vue
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>