PluginProbe
The Innovative Form Builder – IvyForms / 0.4
The Innovative Form Builder – IvyForms v0.4
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / input / IvyTextNumberInput.vue

IvyTextNumberInput.vue in The Innovative Form Builder – IvyForms 0.4, at frontend/src/views/_components/input/IvyTextNumberInput.vue

301 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 class="ivyforms-text-number has-stepper"
4 :class="{
5 'is-secondary': secondary,
6 'is-filled': filled,
7 'is-disabled': disabled,
8 'is-readonly': readOnly,
9 }"
10 >
11 <IvyTextInput
12 ref="textInputRef"
13 v-model="displayValue"
14 :type="'text'"
15 :placeholder="placeholder"
16 class="ivyforms-text-number__input"
17 :clearable="clearable"
18 :disabled="disabled"
19 :readonly="readOnly"
20 :required="required"
21 @focus="onFocus"
22 @blur="onBlur"
23 @input="onRawInput"
24 @keydown.up.prevent="increment"
25 @keydown.down.prevent="decrement"
26 >
27 <template #prefix>
28 <button
29 type="button"
30 class="ivyforms-text-number__btn ivyforms-text-number__btn--dec"
31 :disabled="!canDecrement || disabled || readOnly"
32 @mousedown.prevent
33 @click.prevent.stop="decrement"
34 >
35 <IvyIcon name="minus" size="s" type="outline" color="var(--map-base-dusk-symbol-2)" />
36 </button>
37 </template>
38 <template #suffix>
39 <button
40 type="button"
41 class="ivyforms-text-number__btn ivyforms-text-number__btn--inc"
42 :disabled="!canIncrement || disabled || readOnly"
43 @mousedown.prevent
44 @click.prevent.stop="increment"
45 >
46 <IvyIcon name="plus" size="s" type="outline" color="var(--map-base-dusk-symbol-2)" />
47 </button>
48 </template>
49 </IvyTextInput>
50 </div>
51 </template>
52
53 <script setup lang="ts">
54 import { computed, ref, watch } from 'vue'
55 import IvyTextInput from './IvyTextInput.vue'
56 import {
57 formatNumber,
58 sanitizeRaw,
59 parseToNumber,
60 formatNumberForField,
61 } from '@/utils/utilNumberFormatter'
62
63 /**
64 * A text-based number input that supports locale-like formatting (US / EU) while
65 * still exposing numeric modelValue. Accepts only digits, minus sign, dot, comma.
66 */
67 interface Props {
68 modelValue: number | null | undefined
69 numberFormat?: 'us_decimal' | 'us_int' | 'eu_decimal' | 'eu_int' | ''
70 min?: number
71 max?: number
72 step?: number // reserved for potential future use (manual step buttons)
73 placeholder?: string
74 filled?: boolean
75 secondary?: boolean
76 clearable?: boolean
77 disabled?: boolean
78 readOnly?: boolean
79 required?: boolean
80 }
81
82 const props = withDefaults(defineProps<Props>(), {
83 numberFormat: '',
84 min: Number.MIN_SAFE_INTEGER,
85 max: Number.MAX_SAFE_INTEGER,
86 step: 1,
87 placeholder: '',
88 filled: false,
89 secondary: false,
90 clearable: false,
91 disabled: false,
92 readOnly: false,
93 required: false,
94 })
95
96 const emit = defineEmits<{
97 (e: 'update:modelValue', value: number | null): void
98 (e: 'input', raw: string): void
99 (e: 'invalid', reason: string): void
100 }>()
101
102 // Internal display string (formatted)
103 const displayValue = ref('')
104 // Flag: when focused we show unformatted raw (except thousand separators) to ease editing
105 const isFocused = ref(false)
106 const textInputRef = ref()
107
108 // Update display when external model changes
109 watch(
110 () => props.modelValue,
111 (val) => {
112 if (isFocused.value) {
113 // show unformatted raw raw value (with appropriate decimal char)
114 if (val === null || val === undefined || isNaN(val)) displayValue.value = ''
115 else {
116 let raw = String(val)
117 if (props.numberFormat === 'eu_decimal') raw = raw.replace('.', ',')
118 if (props.numberFormat === 'eu_int' || props.numberFormat === 'us_int')
119 raw = String(Math.trunc(val))
120 displayValue.value = raw
121 }
122 } else {
123 displayValue.value = formatNumberForField(val ?? null, props.numberFormat)
124 }
125 },
126 { immediate: true },
127 )
128
129 // Handle raw input event while focused
130 const onRawInput = (e: Event | string) => {
131 const original =
132 typeof e === 'string' ? e : ((e.target as HTMLInputElement | null)?.value ?? displayValue.value)
133 const sanitized = sanitizeRaw(original, props.numberFormat)
134 if (sanitized !== original) {
135 displayValue.value = sanitized
136 }
137 emit('input', sanitized)
138 const parsed = parseToNumber(sanitized, props.numberFormat, props.min, props.max)
139 if (parsed === null) {
140 emit('update:modelValue', null)
141 } else {
142 emit('update:modelValue', parsed)
143 }
144 }
145
146 const onFocus = () => {
147 isFocused.value = true
148 // Switch to raw representation (remove thousands separators)
149 const current = props.modelValue
150 if (current === null || current === undefined || isNaN(current)) {
151 displayValue.value = ''
152 } else {
153 let raw = String(current)
154 if (props.numberFormat === 'eu_decimal') raw = raw.replace('.', ',')
155 if (props.numberFormat === 'eu_int' || props.numberFormat === 'us_int')
156 raw = String(Math.trunc(current))
157 displayValue.value = raw
158 }
159 }
160
161 const onBlur = () => {
162 isFocused.value = false
163 // Reformat display value using formatter
164 const num = parseToNumber(displayValue.value, props.numberFormat, props.min, props.max)
165 if (num === null) {
166 displayValue.value = ''
167 emit('update:modelValue', null)
168 } else {
169 displayValue.value = formatNumberForField(num, props.numberFormat)
170 emit('update:modelValue', num)
171 }
172 }
173
174 // Add step computed
175 const stepValue = computed(() => (props.step && props.step > 0 ? props.step : 1))
176
177 // Capability flags (updated to consider disabled/readOnly in template only)
178 const canIncrement = computed(() => {
179 if (props.modelValue === null || props.modelValue === undefined) return true
180 return props.modelValue + stepValue.value <= props.max
181 })
182 const canDecrement = computed(() => {
183 if (props.modelValue === null || props.modelValue === undefined) return true
184 return props.modelValue - stepValue.value >= props.min
185 })
186
187 const applyIntConstraint = (val: number) => {
188 if (props.numberFormat === 'us_int' || props.numberFormat === 'eu_int') return Math.trunc(val)
189 return val
190 }
191
192 const clamp = (val: number) => Math.min(Math.max(val, props.min), props.max)
193
194 const baseValueForNull = () => {
195 // mimic value-on-clear="min" behaviour
196 return props.min !== undefined ? props.min : 0
197 }
198
199 const increment = () => {
200 let current = props.modelValue
201 if (current === null || current === undefined || typeof current !== 'number' || isNaN(current)) {
202 current = baseValueForNull()
203 }
204 const next = clamp(applyIntConstraint(Number(current) + stepValue.value))
205 emit('update:modelValue', next)
206 if (!isFocused.value) displayValue.value = formatNumber(next, props.numberFormat)
207 }
208
209 const decrement = () => {
210 let current = props.modelValue
211 if (current === null || current === undefined || typeof current !== 'number' || isNaN(current)) {
212 current = baseValueForNull()
213 }
214 const next = clamp(applyIntConstraint(Number(current) - stepValue.value))
215 emit('update:modelValue', next)
216 if (!isFocused.value) displayValue.value = formatNumber(next, props.numberFormat)
217 }
218
219 // Expose focus method
220 const focus = () => {
221 ;(textInputRef.value as { focus?: () => void } | null)?.focus?.()
222 }
223
224 defineExpose({ focus })
225 </script>
226
227 <style lang="scss">
228 .ivyforms-text-number {
229 position: relative;
230 display: flex;
231 width: 100%;
232
233 &.is-disabled {
234 opacity: 0.6;
235 cursor: not-allowed;
236 }
237
238 &__input {
239 &.el-input {
240 .el-input__wrapper {
241 padding: 0 44px; // space for buttons
242 text-align: center;
243 }
244 .el-input__inner {
245 text-align: center;
246 }
247 // Make prefix/suffix overlay and not affect centering
248 .el-input__prefix,
249 .el-input__suffix {
250 position: absolute;
251 top: 0;
252 height: 100%;
253 display: flex;
254 align-items: center;
255 pointer-events: none; // buttons inside will re-enable
256 }
257 .el-input__prefix {
258 left: 0;
259 width: 44px;
260 justify-content: center;
261 }
262 .el-input__suffix {
263 right: 0;
264 width: 44px;
265 justify-content: center;
266 }
267
268 &__btn {
269 pointer-events: auto;
270 }
271 }
272 }
273 &__btn {
274 border: 0;
275 background: transparent;
276 cursor: pointer;
277 padding: 0;
278 width: 24px;
279 height: 24px;
280 display: flex;
281 align-items: center;
282 justify-content: center;
283 border-radius: 4px;
284 transition: background 0.15s ease;
285
286 &:hover:not(:disabled) {
287 background: var(--map-base-dusk-o10, rgba(0, 0, 0, 0.05));
288 }
289
290 &:active:not(:disabled) {
291 background: var(--map-base-dusk-o15, rgba(0, 0, 0, 0.08));
292 }
293
294 &:disabled {
295 opacity: 0.35;
296 cursor: not-allowed;
297 }
298 }
299 }
300 </style>
301