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

421 lines 11.9 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-filled': filled,
6 'is-disabled': disabled,
7 'is-readonly': readOnly,
8 }"
9 >
10 <IvyTextInput
11 ref="textInputRef"
12 v-model="displayValue"
13 :type="'text'"
14 :placeholder="placeholder"
15 :secondary="secondary"
16 class="ivyforms-text-number__input"
17 :clearable="clearable"
18 :disabled="disabled"
19 :readonly="readOnly"
20 :required="required"
21 :aria-label="ariaLabel"
22 :inputmode="inputMode"
23 @focus="onFocus"
24 @blur="onBlur"
25 @input="onRawInput"
26 @keydown.up.prevent="increment"
27 @keydown.down.prevent="decrement"
28 @keydown="onKeyDown"
29 @paste.prevent="onPaste"
30 >
31 <template #prefix>
32 <button
33 type="button"
34 class="ivyforms-text-number__btn ivyforms-text-number__btn--dec"
35 :disabled="!canDecrement || disabled || readOnly"
36 :aria-label="decreaseButtonLabel"
37 :title="decreaseButtonLabel"
38 @mousedown.prevent
39 @click.prevent.stop="decrement"
40 >
41 <IvyIcon name="minus" size="s" type="outline" color="var(--map-base-dusk-symbol-2)" />
42 </button>
43 </template>
44 <template #suffix>
45 <button
46 type="button"
47 class="ivyforms-text-number__btn ivyforms-text-number__btn--inc"
48 :disabled="!canIncrement || disabled || readOnly"
49 :aria-label="increaseButtonLabel"
50 :title="increaseButtonLabel"
51 @mousedown.prevent
52 @click.prevent.stop="increment"
53 >
54 <IvyIcon name="plus" size="s" type="outline" color="var(--map-base-dusk-symbol-2)" />
55 </button>
56 </template>
57 </IvyTextInput>
58 </div>
59 </template>
60
61 <script setup lang="ts">
62 import { computed, ref, watch } from 'vue'
63 import IvyTextInput from './IvyTextInput.vue'
64 import {
65 formatNumber,
66 sanitizeRaw,
67 parseToNumber,
68 formatNumberForField,
69 } from '@/utils/utilNumberFormatter'
70 import { useLabels } from '@/composables/useLabels'
71
72 /**
73 * A text-based number input that supports locale-like formatting (US / EU) while
74 * still exposing numeric modelValue. Accepts only digits, minus sign, dot, comma.
75 */
76 interface Props {
77 modelValue: number | null | undefined
78 numberFormat?: 'us_decimal' | 'us_int' | 'eu_decimal' | 'eu_int' | ''
79 min?: number
80 max?: number
81 step?: number // reserved for potential future use (manual step buttons)
82 placeholder?: string
83 filled?: boolean
84 secondary?: boolean
85 clearable?: boolean
86 disabled?: boolean
87 readOnly?: boolean
88 required?: boolean
89 ariaLabel?: string
90 }
91
92 const props = withDefaults(defineProps<Props>(), {
93 numberFormat: '',
94 min: Number.MIN_SAFE_INTEGER,
95 max: Number.MAX_SAFE_INTEGER,
96 step: 1,
97 placeholder: '',
98 filled: false,
99 secondary: false,
100 clearable: false,
101 disabled: false,
102 readOnly: false,
103 required: false,
104 ariaLabel: '',
105 })
106
107 const emit = defineEmits<{
108 (e: 'update:modelValue', value: number | null): void
109 (e: 'input', raw: string): void
110 (e: 'invalid', reason: string): void
111 }>()
112
113 // Internal display string (formatted)
114 const displayValue = ref('')
115 // Flag: when focused we show unformatted raw (except thousand separators) to ease editing
116 const isFocused = ref(false)
117 // Stronger typing for the input ref: can be component instance or native input
118 const textInputRef = ref<null | {
119 input?: HTMLInputElement
120 $el?: HTMLElement
121 focus?: () => void
122 }>(null)
123
124 const { getLabel } = useLabels()
125 const decreaseButtonLabel = computed(() => getLabel('decrease_value') || 'Decrease value')
126 const increaseButtonLabel = computed(() => getLabel('increase_value') || 'Increase value')
127
128 // Determine inputmode for better mobile keyboard UX
129 const inputMode = computed(() => {
130 if (props.numberFormat === 'us_int' || props.numberFormat === 'eu_int') return 'numeric'
131 return 'decimal'
132 })
133
134 // Helper: produce the raw editable string from a numeric model value
135 const rawFromModel = (val: number | null | undefined) => {
136 if (val === null || val === undefined || Number.isNaN(val as number)) return ''
137 let raw = String(val)
138 if (props.numberFormat === 'eu_decimal') raw = raw.replace('.', ',')
139 if (props.numberFormat === 'eu_int' || props.numberFormat === 'us_int')
140 raw = String(Math.trunc(val))
141 return raw
142 }
143
144 // Helper: allowed characters depending on number format
145 const allowedChars = (key: string) => {
146 // If it's a single printable character, validate directly
147 if (key.length === 1) {
148 // digits always allowed; allow minus, dot, comma
149 return /^[0-9]$/.test(key) || key === '-' || key === '.' || key === ','
150 }
151 // Allow common non-character key names produced by numpad / different layouts
152 const extra = ['NumpadSubtract', 'NumpadDecimal', 'Subtract', 'Decimal', 'Minus']
153 return extra.includes(key)
154 }
155
156 // Keydown handler to prevent disallowed input characters while preserving navigation/editing keys
157 const onKeyDown = (e: KeyboardEvent) => {
158 // Allow control combos (copy/paste/cut/select all)
159 if (e.ctrlKey || e.metaKey || e.altKey) return
160
161 const key = e.key
162 // Always allow navigation / editing keys
163 const allowedControlKeys = [
164 'Backspace',
165 'Delete',
166 'ArrowLeft',
167 'ArrowRight',
168 'Home',
169 'End',
170 'Tab',
171 'Enter',
172 ]
173 if (allowedControlKeys.includes(key)) return
174
175 if (!allowedChars(key)) {
176 e.preventDefault()
177 return
178 }
179
180 // Only allow one decimal separator and only one minus at start
181 const inputEl = (textInputRef.value &&
182 (textInputRef.value.input ||
183 textInputRef.value.$el?.querySelector('input'))) as HTMLInputElement | null
184 const cur = inputEl?.value ?? displayValue.value
185
186 // If current is empty, allow minus or decimal (so user can start with those)
187 // Determine decimal char based on format
188 const decimalChar = props.numberFormat === 'eu_decimal' ? ',' : '.'
189
190 if (key === decimalChar) {
191 // If already contains a decimal separator (either one), block
192 if ((cur ?? '').includes('.') || (cur ?? '').includes(',')) {
193 e.preventDefault()
194 }
195 return
196 }
197
198 if (key === '-' || key === 'Minus' || key === 'Subtract') {
199 const selectionStart = inputEl?.selectionStart ?? 0
200 if ((cur ?? '').includes('-') || selectionStart !== 0) {
201 e.preventDefault()
202 }
203 return
204 }
205 }
206
207 // Handle paste: sanitize pasted text before inserting
208 const onPaste = (e: ClipboardEvent) => {
209 const text = e.clipboardData?.getData('text') ?? ''
210 if (!text) return
211 const sanitized = sanitizeRaw(text, props.numberFormat)
212
213 // Insert sanitized text at cursor position
214 const inputEl = (textInputRef.value &&
215 (textInputRef.value.input ||
216 textInputRef.value.$el?.querySelector('input'))) as HTMLInputElement | null
217 if (!inputEl) return
218 const start = inputEl.selectionStart ?? 0
219 const end = inputEl.selectionEnd ?? 0
220 const before = inputEl.value.slice(0, start)
221 const after = inputEl.value.slice(end)
222 const next = before + sanitized + after
223 displayValue.value = next
224
225 // Set caret after the inserted sanitized text
226 const caret = start + sanitized.length
227 requestAnimationFrame(() => {
228 try {
229 inputEl.setSelectionRange(caret, caret)
230 } catch {
231 // ignore if not supported
232 }
233 })
234
235 // Manually trigger parsing/emit
236 const parsed = parseToNumber(next, props.numberFormat, props.min, props.max)
237 if (parsed === null) emit('update:modelValue', null)
238 else emit('update:modelValue', parsed)
239 }
240
241 // Update display when external model changes
242 watch(
243 () => props.modelValue,
244 (val) => {
245 if (isFocused.value) {
246 displayValue.value = rawFromModel(val)
247 } else {
248 displayValue.value = formatNumberForField(val ?? null, props.numberFormat)
249 }
250 },
251 { immediate: true },
252 )
253
254 // Handle raw input event while focused
255 const onRawInput = (e: Event | string) => {
256 const original =
257 typeof e === 'string' ? e : ((e.target as HTMLInputElement | null)?.value ?? displayValue.value)
258 const sanitized = sanitizeRaw(original, props.numberFormat)
259 if (sanitized !== original) {
260 displayValue.value = sanitized
261 }
262 emit('input', sanitized)
263 const parsed = parseToNumber(sanitized, props.numberFormat, props.min, props.max)
264 if (parsed === null) {
265 emit('update:modelValue', null)
266 } else {
267 emit('update:modelValue', parsed)
268 }
269 }
270
271 const onFocus = () => {
272 isFocused.value = true
273 displayValue.value = rawFromModel(props.modelValue)
274 }
275
276 const onBlur = () => {
277 isFocused.value = false
278 // Reformat display value using formatter
279 const num = parseToNumber(displayValue.value, props.numberFormat, props.min, props.max)
280 if (num === null) {
281 displayValue.value = ''
282 emit('update:modelValue', null)
283 } else {
284 displayValue.value = formatNumberForField(num, props.numberFormat)
285 emit('update:modelValue', num)
286 }
287 }
288
289 // Add step computed
290 const stepValue = computed(() => (props.step && props.step > 0 ? props.step : 1))
291
292 // Capability flags (updated to consider disabled/readOnly in template only)
293 const canIncrement = computed(() => {
294 if (props.modelValue === null || props.modelValue === undefined) return true
295 return props.modelValue + stepValue.value <= props.max
296 })
297 const canDecrement = computed(() => {
298 if (props.modelValue === null || props.modelValue === undefined) return true
299 return props.modelValue - stepValue.value >= props.min
300 })
301
302 const applyIntConstraint = (val: number) => {
303 if (props.numberFormat === 'us_int' || props.numberFormat === 'eu_int') return Math.trunc(val)
304 return val
305 }
306
307 const clamp = (val: number) => Math.min(Math.max(val, props.min), props.max)
308
309 const increment = () => {
310 let current = props.modelValue
311 if (current === null || current === undefined || typeof current !== 'number' || isNaN(current)) {
312 current = 0
313 }
314 const next = clamp(applyIntConstraint(Number(current) + stepValue.value))
315 emit('update:modelValue', next)
316 if (!isFocused.value) displayValue.value = formatNumber(next, props.numberFormat)
317 }
318
319 const decrement = () => {
320 let current = props.modelValue
321 if (current === null || current === undefined || typeof current !== 'number' || isNaN(current)) {
322 current = 0
323 }
324 const next = clamp(applyIntConstraint(Number(current) - stepValue.value))
325 emit('update:modelValue', next)
326 if (!isFocused.value) displayValue.value = formatNumber(next, props.numberFormat)
327 }
328
329 // Expose focus method
330 const focus = () => {
331 ;(textInputRef.value as { focus?: () => void } | null)?.focus?.()
332 }
333
334 defineExpose({ focus })
335 </script>
336
337 <style lang="scss">
338 .ivyforms-text-number {
339 position: relative;
340 display: flex;
341 width: 100%;
342
343 &.is-disabled {
344 opacity: 0.6;
345 cursor: not-allowed;
346 }
347
348 &.is-disabled.is-readonly {
349 opacity: 1;
350 }
351
352 &__input {
353 &.el-input {
354 .el-input__wrapper {
355 padding: 0 44px; // space for buttons
356 text-align: center;
357 }
358 .el-input__inner {
359 text-align: center;
360 }
361 // Make prefix/suffix overlay and not affect centering
362 .el-input__prefix,
363 .el-input__suffix {
364 position: absolute;
365 top: 0;
366 height: 100%;
367 display: flex;
368 align-items: center;
369 pointer-events: none; // buttons inside will re-enable
370 }
371 .el-input__prefix {
372 left: 0;
373 width: 44px;
374 justify-content: center;
375 }
376 .el-input__suffix {
377 right: 0;
378 width: 44px;
379 justify-content: center;
380 }
381
382 &__btn {
383 pointer-events: auto;
384 }
385 }
386 }
387 &__btn {
388 border: 0;
389 background: transparent;
390 cursor: pointer;
391 padding: 0;
392 width: 24px;
393 height: 24px;
394 display: flex;
395 align-items: center;
396 justify-content: center;
397 border-radius: 4px;
398 transition: background 0.15s ease;
399
400 &:hover:not(:disabled):not(.is-readonly) {
401 background: var(--map-base-dusk-o10, rgba(0, 0, 0, 0.05));
402 }
403
404 &:active:not(:disabled):not(.is-readonly) {
405 background: var(--map-base-dusk-o15, rgba(0, 0, 0, 0.08));
406 }
407
408 &:disabled {
409 opacity: 0.35;
410 cursor: not-allowed;
411 }
412 }
413
414 &.is-readonly {
415 .ivyforms-text-number__btn:disabled {
416 opacity: 1;
417 }
418 }
419 }
420 </style>
421