PluginProbe
The Innovative Form Builder – IvyForms / 0.6
The Innovative Form Builder – IvyForms v0.6
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.6, at frontend/src/views/_components/input/IvyTextNumberInput.vue

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