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

409 lines 11.6 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="getLabel('decrease_value')"
37 :title="getLabel('decrease_value')"
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="getLabel('increase_value')"
50 :title="getLabel('increase_value')"
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
126 // Determine inputmode for better mobile keyboard UX
127 const inputMode = computed(() => {
128 if (props.numberFormat === 'us_int' || props.numberFormat === 'eu_int') return 'numeric'
129 return 'decimal'
130 })
131
132 // Helper: produce the raw editable string from a numeric model value
133 const rawFromModel = (val: number | null | undefined) => {
134 if (val === null || val === undefined || Number.isNaN(val as number)) return ''
135 let raw = String(val)
136 if (props.numberFormat === 'eu_decimal') raw = raw.replace('.', ',')
137 if (props.numberFormat === 'eu_int' || props.numberFormat === 'us_int')
138 raw = String(Math.trunc(val))
139 return raw
140 }
141
142 // Helper: allowed characters depending on number format
143 const allowedChars = (key: string) => {
144 // If it's a single printable character, validate directly
145 if (key.length === 1) {
146 // digits always allowed; allow minus, dot, comma
147 return /^[0-9]$/.test(key) || key === '-' || key === '.' || key === ','
148 }
149 // Allow common non-character key names produced by numpad / different layouts
150 const extra = ['NumpadSubtract', 'NumpadDecimal', 'Subtract', 'Decimal', 'Minus']
151 return extra.includes(key)
152 }
153
154 // Keydown handler to prevent disallowed input characters while preserving navigation/editing keys
155 const onKeyDown = (e: KeyboardEvent) => {
156 // Allow control combos (copy/paste/cut/select all)
157 if (e.ctrlKey || e.metaKey || e.altKey) return
158
159 const key = e.key
160 // Always allow navigation / editing keys
161 const allowedControlKeys = [
162 'Backspace',
163 'Delete',
164 'ArrowLeft',
165 'ArrowRight',
166 'Home',
167 'End',
168 'Tab',
169 'Enter',
170 ]
171 if (allowedControlKeys.includes(key)) return
172
173 if (!allowedChars(key)) {
174 e.preventDefault()
175 return
176 }
177
178 // Only allow one decimal separator and only one minus at start
179 const inputEl = (textInputRef.value &&
180 (textInputRef.value.input ||
181 textInputRef.value.$el?.querySelector('input'))) as HTMLInputElement | null
182 const cur = inputEl?.value ?? displayValue.value
183
184 // If current is empty, allow minus or decimal (so user can start with those)
185 // Determine decimal char based on format
186 const decimalChar = props.numberFormat === 'eu_decimal' ? ',' : '.'
187
188 if (key === decimalChar) {
189 // If already contains a decimal separator (either one), block
190 if ((cur ?? '').includes('.') || (cur ?? '').includes(',')) {
191 e.preventDefault()
192 }
193 return
194 }
195
196 if (key === '-' || key === 'Minus' || key === 'Subtract') {
197 const selectionStart = inputEl?.selectionStart ?? 0
198 if ((cur ?? '').includes('-') || selectionStart !== 0) {
199 e.preventDefault()
200 }
201 return
202 }
203 }
204
205 // Handle paste: sanitize pasted text before inserting
206 const onPaste = (e: ClipboardEvent) => {
207 const text = e.clipboardData?.getData('text') ?? ''
208 if (!text) return
209 const sanitized = sanitizeRaw(text, props.numberFormat)
210
211 // Insert sanitized text at cursor position
212 const inputEl = (textInputRef.value &&
213 (textInputRef.value.input ||
214 textInputRef.value.$el?.querySelector('input'))) as HTMLInputElement | null
215 if (!inputEl) return
216 const start = inputEl.selectionStart ?? 0
217 const end = inputEl.selectionEnd ?? 0
218 const before = inputEl.value.slice(0, start)
219 const after = inputEl.value.slice(end)
220 const next = before + sanitized + after
221 displayValue.value = next
222
223 // Set caret after the inserted sanitized text
224 const caret = start + sanitized.length
225 requestAnimationFrame(() => {
226 try {
227 inputEl.setSelectionRange(caret, caret)
228 } catch {
229 // ignore if not supported
230 }
231 })
232
233 // Manually trigger parsing/emit
234 const parsed = parseToNumber(next, props.numberFormat, props.min, props.max)
235 if (parsed === null) emit('update:modelValue', null)
236 else emit('update:modelValue', parsed)
237 }
238
239 // Update display when external model changes
240 watch(
241 () => props.modelValue,
242 (val) => {
243 if (isFocused.value) {
244 displayValue.value = rawFromModel(val)
245 } else {
246 displayValue.value = formatNumberForField(val ?? null, props.numberFormat)
247 }
248 },
249 { immediate: true },
250 )
251
252 // Handle raw input event while focused
253 const onRawInput = (e: Event | string) => {
254 const original =
255 typeof e === 'string' ? e : ((e.target as HTMLInputElement | null)?.value ?? displayValue.value)
256 const sanitized = sanitizeRaw(original, props.numberFormat)
257 if (sanitized !== original) {
258 displayValue.value = sanitized
259 }
260 emit('input', sanitized)
261 const parsed = parseToNumber(sanitized, props.numberFormat, props.min, props.max)
262 if (parsed === null) {
263 emit('update:modelValue', null)
264 } else {
265 emit('update:modelValue', parsed)
266 }
267 }
268
269 const onFocus = () => {
270 isFocused.value = true
271 displayValue.value = rawFromModel(props.modelValue)
272 }
273
274 const onBlur = () => {
275 isFocused.value = false
276 // Reformat display value using formatter
277 const num = parseToNumber(displayValue.value, props.numberFormat, props.min, props.max)
278 if (num === null) {
279 displayValue.value = ''
280 emit('update:modelValue', null)
281 } else {
282 displayValue.value = formatNumberForField(num, props.numberFormat)
283 emit('update:modelValue', num)
284 }
285 }
286
287 // Add step computed
288 const stepValue = computed(() => (props.step && props.step > 0 ? props.step : 1))
289
290 // Capability flags (updated to consider disabled/readOnly in template only)
291 const canIncrement = computed(() => {
292 if (props.modelValue === null || props.modelValue === undefined) return true
293 return props.modelValue + stepValue.value <= props.max
294 })
295 const canDecrement = computed(() => {
296 if (props.modelValue === null || props.modelValue === undefined) return true
297 return props.modelValue - stepValue.value >= props.min
298 })
299
300 const applyIntConstraint = (val: number) => {
301 if (props.numberFormat === 'us_int' || props.numberFormat === 'eu_int') return Math.trunc(val)
302 return val
303 }
304
305 const clamp = (val: number) => Math.min(Math.max(val, props.min), props.max)
306
307 const increment = () => {
308 let current = props.modelValue
309 if (current === null || current === undefined || typeof current !== 'number' || isNaN(current)) {
310 current = 0
311 }
312 const next = clamp(applyIntConstraint(Number(current) + stepValue.value))
313 emit('update:modelValue', next)
314 if (!isFocused.value) displayValue.value = formatNumber(next, props.numberFormat)
315 }
316
317 const decrement = () => {
318 let current = props.modelValue
319 if (current === null || current === undefined || typeof current !== 'number' || isNaN(current)) {
320 current = 0
321 }
322 const next = clamp(applyIntConstraint(Number(current) - stepValue.value))
323 emit('update:modelValue', next)
324 if (!isFocused.value) displayValue.value = formatNumber(next, props.numberFormat)
325 }
326
327 // Expose focus method
328 const focus = () => {
329 ;(textInputRef.value as { focus?: () => void } | null)?.focus?.()
330 }
331
332 defineExpose({ focus })
333 </script>
334
335 <style lang="scss">
336 .ivyforms-text-number {
337 position: relative;
338 display: flex;
339 width: 100%;
340
341 &.is-disabled {
342 opacity: 0.6;
343 cursor: not-allowed;
344 }
345
346 &__input {
347 &.el-input {
348 .el-input__wrapper {
349 padding: 0 44px; // space for buttons
350 text-align: center;
351 }
352 .el-input__inner {
353 text-align: center;
354 }
355 // Make prefix/suffix overlay and not affect centering
356 .el-input__prefix,
357 .el-input__suffix {
358 position: absolute;
359 top: 0;
360 height: 100%;
361 display: flex;
362 align-items: center;
363 pointer-events: none; // buttons inside will re-enable
364 }
365 .el-input__prefix {
366 left: 0;
367 width: 44px;
368 justify-content: center;
369 }
370 .el-input__suffix {
371 right: 0;
372 width: 44px;
373 justify-content: center;
374 }
375
376 &__btn {
377 pointer-events: auto;
378 }
379 }
380 }
381 &__btn {
382 border: 0;
383 background: transparent;
384 cursor: pointer;
385 padding: 0;
386 width: 24px;
387 height: 24px;
388 display: flex;
389 align-items: center;
390 justify-content: center;
391 border-radius: 4px;
392 transition: background 0.15s ease;
393
394 &:hover:not(:disabled):not(.is-readonly) {
395 background: var(--map-base-dusk-o10, rgba(0, 0, 0, 0.05));
396 }
397
398 &:active:not(:disabled):not(.is-readonly) {
399 background: var(--map-base-dusk-o15, rgba(0, 0, 0, 0.08));
400 }
401
402 &:disabled {
403 opacity: 0.35;
404 cursor: not-allowed;
405 }
406 }
407 }
408 </style>
409