PluginProbe
The Innovative Form Builder – IvyForms / 1.0
The Innovative Form Builder – IvyForms v1.0
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 / IvyMixedInput.vue

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

434 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 v-bind="{ ...$attrs, class: undefined }"
4 :class="[
5 'ivyforms-mixed-input ivyforms-align-items-center ivyforms-pl-12 ivyforms-py-0 ivyforms-border-radius-8',
6 $attrs.class,
7 {
8 'is-filled': props.filled,
9 'is-secondary': props.secondary,
10 'is-disabled': props.disabled,
11 },
12 ]"
13 >
14 <!-- Prepend slot for icon or custom content -->
15 <div
16 v-if="!!slots['prepend'] || iconPrepend"
17 class="ivyforms-mixed-input__prepend ivyforms-flex ivyforms-align-items-center ivyforms-mr-8 ivyforms-flex-shrink-0"
18 >
19 <slot name="prepend">
20 <IvyIcon
21 v-if="iconPrepend"
22 :name="iconPrepend"
23 :category="iconPrependCategory"
24 :type="iconPrependType"
25 class="ivyforms-mixed-input__icon-prepend ivyforms-flex-shrink-0"
26 size="l"
27 :color="iconColor"
28 />
29 </slot>
30 </div>
31
32 <!-- Number input section -->
33 <div
34 class="ivyforms-mixed-input__input ivyforms-flex ivyforms-align-items-center ivyforms-flex-1"
35 >
36 <input
37 ref="inputRef"
38 :value="localValue"
39 type="text"
40 inputmode="numeric"
41 :placeholder="props.placeholder"
42 :disabled="props.disabled"
43 class="ivyforms-mixed-input__field ivyforms-width-100 ivyforms-p-0 ivyforms-m-0"
44 @input="onInput"
45 @focus="isFocused = true"
46 @blur="isFocused = false"
47 />
48 </div>
49
50 <!-- Append slot for unit select or custom content -->
51 <div
52 v-if="!!slots['append'] || hasUnitSelect"
53 class="ivyforms-mixed-input__append ivyforms-flex ivyforms-align-items-center ivyforms-flex-shrink-0"
54 >
55 <slot name="append">
56 <div
57 v-if="hasUnitSelect"
58 ref="unitSelectRef"
59 class="ivyforms-mixed-input__unit-select ivyforms-flex ivyforms-align-items-center ivyforms-gap-4 ivyforms-border-radius-4 ivyforms-px-12 ivyforms-py-8"
60 >
61 <span class="ivyforms-mixed-input__unit-text regular-14">{{ localUnit }}</span>
62 <IvyIcon
63 name="chevron-down"
64 category="arrows"
65 type="line"
66 class="ivyforms-mixed-input__unit-icon ivyforms-flex-shrink-0 ivyforms-cursor-pointer"
67 size="s"
68 color="var(--map-base-dusk-symbol-2)"
69 @click="toggleUnitDropdown"
70 />
71 </div>
72 </slot>
73 </div>
74 </div>
75
76 <Teleport to="body">
77 <div
78 v-if="showUnitDropdown"
79 class="ivyforms-mixed-input__unit-dropdown ivyfors-py-4 ivyforms-border-radius-8"
80 :style="dropdownStyle"
81 :data-secondary="props.secondary || undefined"
82 @mousedown.prevent
83 >
84 <div
85 v-for="unitOption in props.units"
86 :key="unitOption"
87 class="ivyforms-mixed-input__unit-option ivyforms-px-12 ivyforms-py-6 regular-14 ivyforms-cursor-pointer"
88 :class="{ 'is-selected': localUnit === unitOption }"
89 @click="selectUnit(unitOption)"
90 >
91 {{ unitOption }}
92 </div>
93 </div>
94 </Teleport>
95 </template>
96
97 <script setup lang="ts">
98 import { ref, computed, watch, useSlots, onMounted, onUnmounted, type CSSProperties } from 'vue'
99 import type { IconCategory } from '@/types/icons/icon-category'
100 import type { IconType } from '@/types/icons/icon-type'
101
102 defineOptions({ inheritAttrs: false })
103
104 interface Props {
105 modelValue?: number | string | null
106 unit?: string
107 units?: string[]
108 placeholder?: string
109 iconPrepend?: string
110 iconPrependCategory?: IconCategory
111 iconPrependType?: IconType
112 iconColor?: string
113 filled?: boolean
114 secondary?: boolean
115 disabled?: boolean
116 min?: number
117 max?: number
118 step?: number | string
119 allowNegative?: boolean
120 }
121
122 const props = withDefaults(defineProps<Props>(), {
123 modelValue: null,
124 unit: 'px',
125 units: () => ['px', 'rem', 'em', '%'],
126 placeholder: '',
127 iconPrepend: '',
128 iconPrependCategory: 'global',
129 iconPrependType: 'outline',
130 iconColor: 'var(--map-base-dusk-symbol-2)',
131 filled: false,
132 secondary: true,
133 disabled: false,
134 min: undefined,
135 max: undefined,
136 step: 1,
137 allowNegative: false,
138 })
139 // Expose iconColor for template
140 const iconColor = computed(() => props.iconColor)
141
142 const emit = defineEmits(['update:modelValue', 'update:unit'])
143
144 const slots = useSlots()
145 const inputRef = ref<HTMLInputElement | null>(null)
146 const unitSelectRef = ref<HTMLElement | null>(null)
147
148 const modelValueToString = (val: number | string | null | undefined): string => {
149 if (val === null || val === undefined || val === '') return ''
150 return String(val)
151 }
152
153 const localValue = ref<string>(modelValueToString(props.modelValue))
154 const localUnit = ref<string>(props.unit)
155 const isFocused = ref(false)
156 const showUnitDropdown = ref(false)
157
158 const hasUnitSelect = computed(() => props.units && props.units.length > 0)
159
160 const dropdownPos = ref({ top: 0, right: 0 })
161
162 const dropdownStyle = computed<CSSProperties>(() => ({
163 position: 'fixed',
164 top: `${dropdownPos.value.top}px`,
165 right: `${dropdownPos.value.right}px`,
166 left: 'auto',
167 zIndex: 9999,
168 }))
169
170 const updateDropdownPos = () => {
171 if (!unitSelectRef.value) return
172 const rect = unitSelectRef.value.getBoundingClientRect()
173 dropdownPos.value = {
174 top: rect.bottom + 4,
175 right: window.innerWidth - rect.right,
176 }
177 }
178
179 // Watch for external modelValue changes (e.g. syncWithStore) but don't
180 // overwrite when the user is actively editing to avoid cursor jumps.
181 watch(
182 () => props.modelValue,
183 (newValue) => {
184 const newStr = modelValueToString(newValue)
185 // Only update if the external value actually differs from what we'd parse
186 // from the current local string, so mid-typing states are preserved.
187 const parsed = localValue.value !== '' ? Number(localValue.value) : null
188 const incoming =
189 newValue === null || newValue === undefined || newValue === '' ? null : Number(newValue)
190 if (parsed !== incoming) {
191 localValue.value = newStr
192 }
193 },
194 )
195
196 // Watch for external unit changes
197 watch(
198 () => props.unit,
199 (newValue) => {
200 localUnit.value = newValue
201 },
202 )
203
204 const onInput = (event: Event) => {
205 const raw = (event.target as HTMLInputElement).value
206
207 // Allow only digits, an optional leading minus (if allowNegative) and one decimal point.
208 const allowedPattern = props.allowNegative ? /[^0-9.-]/g : /[^0-9.]/g
209 // Strip disallowed characters
210 let sanitised = raw.replace(allowedPattern, '')
211 // Ensure only one decimal point
212 const firstDot = sanitised.indexOf('.')
213 if (firstDot !== -1) {
214 sanitised = sanitised.slice(0, firstDot + 1) + sanitised.slice(firstDot + 1).replace('.', '')
215 }
216 // Ensure minus only at the start
217 if (props.allowNegative) {
218 const minusCount = (sanitised.match(/-/g) || []).length
219 if (minusCount > 1 || sanitised.indexOf('-') > 0) {
220 sanitised = sanitised.replace(/-/g, '')
221 if (raw.startsWith('-')) sanitised = '-' + sanitised
222 }
223 }
224
225 // Update the DOM input if we stripped characters
226 if ((event.target as HTMLInputElement).value !== sanitised) {
227 ;(event.target as HTMLInputElement).value = sanitised
228 }
229
230 localValue.value = sanitised
231
232 // Emit: empty string → null, valid number → number, otherwise don't emit yet
233 if (sanitised === '' || sanitised === '-' || sanitised === '.') {
234 emit('update:modelValue', sanitised === '' ? null : sanitised)
235 } else {
236 const num = Number(sanitised)
237 if (!isNaN(num)) {
238 // Clamp to min/max if defined
239 const clamped =
240 props.min !== undefined && num < props.min
241 ? props.min
242 : props.max !== undefined && num > props.max
243 ? props.max
244 : num
245 // Only clamp the emit value, not the display value, to allow typing freely
246 emit('update:modelValue', clamped)
247 }
248 }
249 }
250
251 // Unit selection handlers
252 const toggleUnitDropdown = () => {
253 if (!props.disabled) {
254 if (!showUnitDropdown.value) {
255 updateDropdownPos()
256 }
257 showUnitDropdown.value = !showUnitDropdown.value
258 }
259 }
260
261 const selectUnit = (unit: string) => {
262 localUnit.value = unit
263 emit('update:unit', unit)
264 showUnitDropdown.value = false
265 }
266
267 // Close dropdown when clicking outside
268 const handleClickOutside = (event: MouseEvent) => {
269 const target = event.target as HTMLElement
270 if (
271 showUnitDropdown.value &&
272 !target.closest('.ivyforms-mixed-input__unit-select') &&
273 !target.closest('.ivyforms-mixed-input__unit-dropdown')
274 ) {
275 showUnitDropdown.value = false
276 }
277 }
278
279 onMounted(() => {
280 document.addEventListener('click', handleClickOutside)
281 })
282
283 onUnmounted(() => {
284 document.removeEventListener('click', handleClickOutside)
285 })
286
287 const focus = () => {
288 inputRef.value?.focus()
289 }
290
291 defineExpose({
292 focus,
293 })
294 </script>
295
296 <style scoped lang="scss">
297 .ivyforms-mixed-input {
298 display: inline-flex;
299 height: 40px;
300 border: 1px solid var(--map-base-dusk-stroke--2);
301 background: var(--map-base-dusk-o05);
302 transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
303 position: relative;
304 box-sizing: border-box;
305 box-shadow: none !important;
306 outline: none !important;
307
308 &:hover:not(.is-disabled) {
309 border-color: var(--map-base-dusk-stroke-0);
310 background: var(--map-base-dusk-o05);
311 }
312
313 &:focus-within:not(.is-disabled) {
314 border-color: var(--map-base-brand-stroke-0);
315 background: var(--map-base-dusk-o05);
316 box-shadow: none !important;
317 }
318
319 &.is-disabled {
320 background: var(--map-base-dusk-o10);
321 cursor: not-allowed;
322
323 .ivyforms-mixed-input__field,
324 .ivyforms-mixed-input__unit-select {
325 cursor: not-allowed;
326 pointer-events: none;
327 }
328 }
329
330 &.is-secondary:focus-within:not(.is-disabled) {
331 border-color: var(--map-base-purple-stroke-0);
332 box-shadow: none !important;
333 }
334
335 // Input section
336 &__input {
337 min-width: 0;
338 }
339
340 &__field {
341 border: none !important;
342 background: transparent !important;
343 color: var(--map-base-text-0);
344 outline: none !important;
345 box-shadow: none !important;
346
347 &::placeholder {
348 color: var(--map-base-text--2);
349 }
350
351 &:disabled {
352 cursor: not-allowed;
353 color: var(--map-base-text--2);
354 }
355 }
356
357 // Append section (unit select)
358 &__unit-select {
359 cursor: pointer;
360 position: relative;
361 user-select: none;
362 transition: background-color 0.2s ease;
363
364 &:hover {
365 background: var(--map-base-dusk-o10);
366 }
367 }
368
369 &__unit-text {
370 color: var(--map-base-text--1);
371 min-width: 20px;
372 }
373
374 // Unit dropdown
375 // Filled variant
376 &.is-filled {
377 background: var(--map-base-dusk-o10);
378 }
379 }
380
381 // Error and warning states (for use in form item context)
382 .el-form-item {
383 &.is-error {
384 .ivyforms-mixed-input {
385 border-color: var(--map-status-error-symbol-0) !important;
386 box-shadow: 0 0 0 1px var(--map-status-error-symbol-0) inset !important;
387 }
388 }
389
390 &.is-warning {
391 .ivyforms-mixed-input {
392 border-color: var(--map-status-warning-stroke-0) !important;
393 box-shadow: 0 0 0 1px var(--map-status-warning-stroke-0) inset !important;
394 }
395 }
396 }
397 </style>
398
399 <!-- Global styles for the teleported unit dropdown (cannot be scoped) -->
400 <style lang="scss">
401 .ivyforms-mixed-input__unit-dropdown {
402 min-width: 64px;
403 overflow: hidden;
404 border-radius: 8px;
405 padding: 4px 0;
406 background: var(--map-ground-level-3);
407 border: 1px solid var(--map-base-dusk-stroke--2);
408 box-shadow:
409 0px 4px 15px 0px rgba(0, 0, 0, 0.05),
410 0px 2px 6px 0px rgba(0, 0, 0, 0.03);
411 }
412
413 .ivyforms-mixed-input__unit-option {
414 color: var(--map-base-text-0);
415 transition: background-color 0.15s ease;
416
417 &:hover {
418 background-color: var(--map-base-dusk-o05);
419 }
420
421 &.is-selected {
422 background-color: var(--map-base-brand-o10);
423 color: var(--map-base-brand-symbol-0);
424 }
425 }
426
427 .ivyforms-mixed-input__unit-dropdown[data-secondary]] {
428 .ivyforms-mixed-input__unit-option.is-selected {
429 background-color: var(--map-base-purple-o10);
430 color: var(--map-base-purple-symbol-0);
431 }
432 }
433 </style>
434