PluginProbe
The Innovative Form Builder – IvyForms / 1.1
The Innovative Form Builder – IvyForms v1.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 / public / fields / TimeFormField.vue

TimeFormField.vue in The Innovative Form Builder – IvyForms 1.1, at frontend/src/views/public/fields/TimeFormField.vue

628 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 :class="[
4 'ivyforms-field',
5 'ivyforms-field__time',
6 'ivyforms-field__time_' + field.id,
7 field.cssClasses,
8 {
9 'ivyforms-field__time--is-readonly': !!field.readOnly,
10 'is-readonly': !!field.readOnly,
11 },
12 ]"
13 >
14 <IvyFormItem
15 v-if="field.timeFieldType === 'time-picker'"
16 :label="field.hideLabel ? '' : field.label"
17 :required="field.required"
18 :error="displayError"
19 :prop="field.type + '_' + field.fieldIndex"
20 :show-info="!!fieldInfoText"
21 :info-text="fieldInfoText"
22 :show-info-icon="false"
23 :label-position="field.labelPosition"
24 >
25 <!-- time-picker mode -->
26 <IvyTimePicker
27 :id="fieldID"
28 v-model="localModelValue"
29 :aria-label="field.label"
30 class="ivyforms-field__time-picker"
31 :time-format="field.timeFormat"
32 :placeholder="field.placeholder"
33 :disabled="disabled || field.readOnly"
34 :readonly="field.readOnly"
35 :clearable="true"
36 :disabled-hours="disabledHours"
37 :disabled-minutes="disabledMinutes"
38 />
39 </IvyFormItem>
40
41 <!-- free text input mode -->
42 <IvyFormItem
43 v-if="field.timeFieldType === 'input'"
44 :label="field.hideLabel ? '' : field.label"
45 :required="field.required"
46 :error="displayError"
47 :prop="field.type + '_' + field.fieldIndex"
48 :show-info="!!fieldInfoText"
49 :info-text="fieldInfoText"
50 :show-info-icon="false"
51 :label-position="field.labelPosition"
52 >
53 <div class="ivyforms-flex ivyforms-gap-12 ivyforms-width-100">
54 <div :class="['ivyforms-flex', 'ivyforms-flex-1', 'ivyforms-flex-direction-column']">
55 <IvyTextInput
56 :id="fieldID"
57 v-model="localModelValue"
58 :aria-label="field.label"
59 class="ivyforms-field__time-input"
60 type="text"
61 :placeholder="field.placeholder"
62 :disabled="disabled || field.readOnly"
63 :readonly="field.readOnly"
64 :required="field.required"
65 />
66 </div>
67 <div
68 v-if="field.timeFormat === 'ampm'"
69 class="ivyforms-flex ivyforms-flex-1 ivyforms-flex-direction-column"
70 >
71 <IvySelectInput
72 id="meridiem-input-select"
73 v-model="selectedInputMeridiem"
74 aria-label="getLabel('am_pm')"
75 :error="!!(displayError && !selectedInputMeridiem)"
76 :placeholder="getLabel('am_pm')"
77 :disabled="disabled || field.readOnly"
78 field-type="time"
79 @update:model-value="onInputMeridiemChange"
80 >
81 <IvySelectOption value="" :label="getLabel('not_selected')" field-type="time">
82 {{ getLabel('not_selected') }}
83 </IvySelectOption>
84 <IvySelectOption
85 v-for="meridiem in meridiemOptions"
86 :key="meridiem.value"
87 :label="meridiem.label"
88 :value="meridiem.value"
89 field-type="time"
90 >
91 {{ meridiem.label }}
92 </IvySelectOption>
93 </IvySelectInput>
94 </div>
95 </div>
96 </IvyFormItem>
97 <IvyFormItem
98 v-if="field.timeFieldType === 'dropdown'"
99 :label="field.label"
100 :required="field.required"
101 :error="displayError"
102 :prop="field.type + '_' + field.fieldIndex"
103 :show-info="!!fieldInfoText"
104 :info-text="fieldInfoText"
105 :show-info-icon="false"
106 :label-position="field.labelPosition"
107 :hidden-label="field.hideLabel"
108 >
109 <!-- dropdown mode -->
110 <div class="ivyforms-flex ivyforms-gap-12 ivyforms-width-100">
111 <div class="ivyforms-flex ivyforms-flex-1 ivyforms-flex-direction-column">
112 <label for="hours-select" class="medium-14 ivyforms-mb-6 ivyforms-field__time__sublabel">
113 {{ getLabel('hours') }}
114 </label>
115 <IvySelectInput
116 id="hours-select"
117 v-model="selectedHour"
118 class="ivyforms-field__time-hour"
119 :error="!!(error && !selectedHour)"
120 placeholder="&#45;&#45;"
121 :disabled="disabled || field.readOnly"
122 field-type="time"
123 @update:model-value="onPartChange"
124 >
125 <IvySelectOption
126 v-for="h in hourOptions"
127 :key="h.value"
128 :label="h.label"
129 :value="h.value"
130 field-type="time"
131 />
132 </IvySelectInput>
133 </div>
134
135 <div class="ivyforms-flex ivyforms-flex-1 ivyforms-flex-direction-column">
136 <label
137 for="minutes-select"
138 class="medium-14 ivyforms-mb-6 ivyforms-field__time__sublabel"
139 >
140 {{ getLabel('minutes') }}
141 </label>
142 <IvySelectInput
143 id="minutes-select"
144 v-model="selectedMinute"
145 class="ivyforms-field__time-minute"
146 :error="!!(error && !selectedMinute)"
147 placeholder="&#45;&#45;"
148 :disabled="disabled || field.readOnly"
149 field-type="time"
150 @update:model-value="onPartChange"
151 >
152 <IvySelectOption
153 v-for="m in minuteOptions"
154 :key="m.value"
155 :label="m.label"
156 :value="m.value"
157 field-type="time"
158 />
159 </IvySelectInput>
160 </div>
161
162 <div
163 v-if="field.timeFormat === 'ampm'"
164 class="ivyforms-flex ivyforms-flex-1 ivyforms-flex-direction-column ivyforms-mt-26"
165 >
166 <IvySelectInput
167 id="meridiem-select"
168 v-model="selectedDropdownMeridiem"
169 :error="!!(error && !selectedDropdownMeridiem)"
170 aria-label="getLabel('am_pm')"
171 :placeholder="getLabel('am_pm')"
172 :disabled="disabled || field.readOnly"
173 field-type="time"
174 @update:model-value="onDropdownMeridiemChange"
175 >
176 <IvySelectOption value="" :label="getLabel('not_selected')" field-type="time">
177 {{ getLabel('not_selected') }}
178 </IvySelectOption>
179 <IvySelectOption
180 v-for="meridiem in meridiemOptions"
181 :key="meridiem.value"
182 :label="meridiem.label"
183 :value="meridiem.value"
184 field-type="time"
185 >
186 {{ meridiem.label }}
187 </IvySelectOption>
188 </IvySelectInput>
189 </div>
190 </div>
191 </IvyFormItem>
192 </div>
193 </template>
194
195 <script setup lang="ts">
196 import { computed, inject, ref, watch } from 'vue'
197 import { useLabels } from '@/composables/useLabels'
198 import { useTimeField } from '@/composables/useTimeField'
199 import type { Field } from '@/types/field'
200 import IvyFormItem from '@/views/_components/form/IvyFormItem.vue'
201 import { TIME_CONSTANTS } from '@/constants/timeConstants'
202
203 const { getLabel } = useLabels()
204
205 interface FieldProps {
206 modelValue: string | null | undefined
207 field: Field
208 disabled?: boolean
209 error?: string
210 }
211
212 const props = withDefaults(defineProps<FieldProps>(), {
213 modelValue: '',
214 disabled: false,
215 error: '',
216 })
217
218 const emit = defineEmits(['update:modelValue'])
219 const clearFieldError = inject<(fieldKey: string) => void>('clearFieldError', () => {})
220 const fieldKey = computed(() => props.field.type + '_' + props.field.fieldIndex)
221 const { parseTimeString } = useTimeField()
222 const pad2 = (number: number): string => (number < 10 ? '0' + number : '' + number)
223
224 // Helper function to check if time is within min/max range
225 const isTimeInRange = (timeString: string): boolean => {
226 if (!props.field.minTimeValue && !props.field.maxTimeValue) {
227 return true
228 }
229
230 if (!timeString || timeString === '__PARTIAL__') {
231 return true // Allow incomplete input
232 }
233
234 const parsed = parseTimeString(timeString)
235 if (!parsed.hours || !parsed.minutes) {
236 return true // Allow incomplete input while typing
237 }
238
239 let hour24 = Number(parsed.hours)
240 const minute = Number(parsed.minutes)
241
242 // Convert to 24h format if needed
243 if (parsed.amPm) {
244 if (parsed.amPm === 'PM' && hour24 !== TIME_CONSTANTS.MERIDIEM_12_HOUR) {
245 hour24 += TIME_CONSTANTS.MIN_HOURS_IN_DAY
246 } else if (parsed.amPm === 'AM' && hour24 === TIME_CONSTANTS.MERIDIEM_12_HOUR) {
247 hour24 = TIME_CONSTANTS.MIN_HOUR_24
248 }
249 }
250
251 // Additional validation for valid time
252 if (
253 hour24 < TIME_CONSTANTS.MIN_HOUR_24 ||
254 hour24 > TIME_CONSTANTS.MAX_HOUR_24 ||
255 minute < TIME_CONSTANTS.MIN_MINUTE ||
256 minute > TIME_CONSTANTS.MAX_MINUTE
257 ) {
258 return false
259 }
260
261 const timeInMinutes = hour24 * TIME_CONSTANTS.MINUTES_PER_HOUR + minute
262
263 let minMinutes = TIME_CONSTANTS.MIN_HOUR_24
264 let maxMinutes = TIME_CONSTANTS.MAX_TIME_IN_MINUTES
265
266 if (props.field.minTimeValue) {
267 const [minH, minM] = props.field.minTimeValue.split(':').map(Number)
268 minMinutes = minH * TIME_CONSTANTS.MINUTES_PER_HOUR + minM
269 }
270
271 if (props.field.maxTimeValue) {
272 const [maxH, maxM] = props.field.maxTimeValue.split(':').map(Number)
273 maxMinutes = maxH * TIME_CONSTANTS.MINUTES_PER_HOUR + maxM
274 }
275
276 return timeInMinutes >= minMinutes && timeInMinutes <= maxMinutes
277 }
278
279 // Generate error message for out of range time
280 const getTimeRangeErrorMessage = (): string => {
281 if (!props.field.minTimeValue && !props.field.maxTimeValue) {
282 return ''
283 }
284
285 if (props.field.minTimeValue && props.field.maxTimeValue) {
286 return `${getLabel('time_must_be_between')} ${props.field.minTimeValue} ${getLabel('and')} ${props.field.maxTimeValue}`
287 } else if (props.field.minTimeValue) {
288 const message = getLabel('time_must_be_after')
289 return message.replace('{min}', props.field.minTimeValue)
290 } else if (props.field.maxTimeValue) {
291 const message = getLabel('time_must_be_before')
292 return message.replace('{max}', props.field.maxTimeValue)
293 }
294
295 return ''
296 }
297
298 const parsedDefaultValue = computed(() => parseTimeString(props.field.defaultValue || ''))
299
300 // Just use the field description without adding range info
301 const fieldInfoText = computed(() => props.field.description || '')
302
303 const selectedHour = ref<string>(parsedDefaultValue.value.hours || '')
304 const selectedMinute = ref<string>(parsedDefaultValue.value.minutes || '')
305 const handleInput = (value: string | null | undefined, shouldClearError: boolean = true) => {
306 emit('update:modelValue', value ?? '')
307 if (shouldClearError) {
308 clearFieldError(fieldKey.value)
309 }
310 }
311 const selectedDropdownMeridiem = ref<'AM' | 'PM' | ''>(
312 props.field.timeFieldType === 'dropdown'
313 ? (parsedDefaultValue.value.amPm as 'AM' | 'PM' | '')
314 : '',
315 )
316 const selectedInputMeridiem = ref<'AM' | 'PM' | ''>(
317 props.field.timeFieldType === 'input' ? (parsedDefaultValue.value.amPm as 'AM' | 'PM' | '') : '',
318 )
319 const inputTime12h = ref<string>('')
320 const timeRangeError = ref<string>('')
321
322 // Computed error message that shows time range error or the field error
323 const displayError = computed(() => {
324 if (timeRangeError.value) {
325 return timeRangeError.value
326 }
327 return props.error
328 })
329
330 const meridiemOptions = computed(() => [
331 { label: getLabel('am'), value: 'AM' },
332 { label: getLabel('pm'), value: 'PM' },
333 ])
334 const localModelValue = computed({
335 get() {
336 if (props.field.timeFieldType === 'input' && props.field.timeFormat === 'ampm') {
337 return inputTime12h.value
338 }
339 if (
340 props.field.timeFieldType === 'time-picker' &&
341 props.field.timeFormat === 'ampm' &&
342 (!props.modelValue || props.modelValue === '')
343 ) {
344 const parsed = parseTimeString(props.field.defaultValue || '')
345 if (parsed.hours && parsed.minutes && parsed.amPm) {
346 return `${pad2(Number(parsed.hours))}:${pad2(Number(parsed.minutes))} ${parsed.amPm}`
347 }
348 }
349 return props.modelValue != null ? String(props.modelValue) : ''
350 },
351 set(timeValue: string) {
352 if (props.field.timeFieldType === 'input' && props.field.timeFormat === 'ampm') {
353 inputTime12h.value = timeValue
354 if (!timeValue || !selectedInputMeridiem.value) {
355 handleInput('', true)
356 return
357 }
358 const timeMatch = timeValue.match(/^(\d{1,2}):(\d{2})$/)
359 if (timeMatch && selectedInputMeridiem.value) {
360 const hour12 = timeMatch[1]
361 const minutes = timeMatch[2]
362 const meridiem = selectedInputMeridiem.value
363 const timeString = `${hour12}:${minutes} ${meridiem}`
364 // Don't validate on every keystroke, only on blur
365 handleInput(timeString, true)
366 } else {
367 handleInput('__PARTIAL__', false)
368 }
369 } else if (props.field.timeFieldType === 'time-picker') {
370 if (timeValue) {
371 handleInput(timeValue, true)
372 } else {
373 handleInput('', true)
374 }
375 } else if (props.field.timeFieldType === 'input') {
376 // For 24h input, let user type freely, validate on blur
377 handleInput(timeValue)
378 } else {
379 handleInput(timeValue)
380 }
381 },
382 })
383
384 function onInputMeridiemChange(value: 'AM' | 'PM' | '') {
385 selectedInputMeridiem.value = value
386 if (props.field.timeFieldType === 'input' && inputTime12h.value) {
387 const timeMatch = inputTime12h.value.match(/^(\d{1,2}):(\d{2})$/)
388 if (timeMatch && value) {
389 const hour12 = timeMatch[1]
390 const minutes = timeMatch[2]
391 const timeString = `${hour12}:${minutes} ${value}`
392
393 // Validate and set error if out of range
394 if (!isTimeInRange(timeString)) {
395 timeRangeError.value = getTimeRangeErrorMessage()
396 } else {
397 timeRangeError.value = ''
398 }
399
400 handleInput(timeString, true)
401 } else {
402 handleInput('__PARTIAL__', false)
403 }
404 }
405 }
406 function onDropdownMeridiemChange(value: 'AM' | 'PM' | '') {
407 selectedDropdownMeridiem.value = value
408 emitFromParts()
409 }
410 const hourOptions = computed(() => {
411 const min = props.field.minTimeValue
412 const max = props.field.maxTimeValue
413 let minHour = TIME_CONSTANTS.MIN_HOUR_24
414 let maxHour = TIME_CONSTANTS.MAX_HOUR_24
415
416 if (min) minHour = Number(min.split(':')[0])
417 if (max) maxHour = Number(max.split(':')[0])
418
419 if (props.field.timeFormat === 'ampm') {
420 return Array.from({ length: TIME_CONSTANTS.MAX_HOUR_12 }, (_, i) => {
421 const value = pad2(i + 1)
422 const hour24 =
423 i + 1 === TIME_CONSTANTS.MERIDIEM_12_HOUR ? TIME_CONSTANTS.MERIDIEM_12_HOUR : i + 1 // Convert 12-hour to check range
424 const isDisabled = hour24 < minHour || hour24 > maxHour
425 return { value, label: value, disabled: isDisabled }
426 }).filter((h) => !h.disabled)
427 } else {
428 return Array.from({ length: TIME_CONSTANTS.MIN_HOURS_IN_DAY }, (_, i) => {
429 const value = pad2(i)
430 const isDisabled = i < minHour || i > maxHour
431 return { value, label: value, disabled: isDisabled }
432 }).filter((h) => !h.disabled)
433 }
434 })
435 const minuteOptions = computed(() => {
436 const min = props.field.minTimeValue
437 const max = props.field.maxTimeValue
438 const currentHour = selectedHour.value ? Number(selectedHour.value) : null
439
440 if (!min && !max) {
441 return Array.from({ length: TIME_CONSTANTS.MINUTES_PER_HOUR }, (_, i) => {
442 const value = pad2(i)
443 return { value, label: value }
444 })
445 }
446
447 let minHour = TIME_CONSTANTS.MIN_HOUR_24,
448 minMinute = TIME_CONSTANTS.MIN_MINUTE,
449 maxHour = TIME_CONSTANTS.MAX_HOUR_24,
450 maxMinute = TIME_CONSTANTS.MAX_MINUTE
451 if (min) {
452 const [h, m] = min.split(':').map(Number)
453 minHour = h
454 minMinute = m
455 }
456 if (max) {
457 const [h, m] = max.split(':').map(Number)
458 maxHour = h
459 maxMinute = m
460 }
461
462 return Array.from({ length: TIME_CONSTANTS.MINUTES_PER_HOUR }, (_, i) => {
463 const value = pad2(i)
464 let isDisabled = false
465
466 if (currentHour !== null) {
467 if (currentHour === minHour && i < minMinute) isDisabled = true
468 if (currentHour === maxHour && i > maxMinute) isDisabled = true
469 }
470
471 return { value, label: value, disabled: isDisabled }
472 }).filter((m) => !m.disabled)
473 })
474 function emitFromParts() {
475 const hasAnyValue = !!(
476 selectedHour.value ||
477 selectedMinute.value ||
478 (props.field.timeFormat === 'ampm' && selectedDropdownMeridiem.value)
479 )
480 const hasAllValues =
481 selectedHour.value &&
482 selectedMinute.value &&
483 (props.field.timeFormat === '24h' || selectedDropdownMeridiem.value)
484 if (!hasAnyValue) {
485 handleInput('', true)
486 return
487 }
488 if (!hasAllValues) {
489 handleInput('__PARTIAL__', false)
490 return
491 }
492 if (props.field.timeFormat === 'ampm') {
493 const hour12 = parseInt(selectedHour.value, 10)
494 const minute = selectedMinute.value
495 const meridiem = selectedDropdownMeridiem.value
496 const timeValue = pad2(hour12) + ':' + minute + ' ' + meridiem
497
498 // Validate against min/max time for dropdown mode
499 if (!isTimeInRange(timeValue)) {
500 timeRangeError.value = getTimeRangeErrorMessage()
501 } else {
502 timeRangeError.value = ''
503 }
504
505 handleInput(timeValue, true)
506 return
507 }
508 const timeValue = selectedHour.value + ':' + selectedMinute.value
509
510 // Validate against min/max time for dropdown mode
511 if (!isTimeInRange(timeValue)) {
512 timeRangeError.value = getTimeRangeErrorMessage()
513 } else {
514 timeRangeError.value = ''
515 }
516
517 handleInput(timeValue, true)
518 }
519 function onPartChange() {
520 emitFromParts()
521 }
522 watch(
523 () => [props.modelValue, props.field.timeFormat],
524 () => {
525 const val = props.modelValue != null ? String(props.modelValue) : ''
526 if (props.field.timeFieldType === 'input' && props.field.timeFormat === 'ampm') {
527 if (val && val !== '__PARTIAL__') {
528 const match = val.match(/^(.*?)(?:\s*(AM|PM|am|pm))?$/)
529 inputTime12h.value = match ? match[1].trim() : ''
530 selectedInputMeridiem.value =
531 match && match[2] ? (match[2].toUpperCase() as 'AM' | 'PM') : ''
532 } else if (!val || val.trim() === '') {
533 inputTime12h.value = ''
534 selectedInputMeridiem.value = ''
535 }
536 } else if (props.field.timeFieldType === 'time-picker') {
537 const ampmRegex = /^(?:0?[1-9]|1[0-2]):[0-5]\d\s?(AM|PM|am|pm)$/
538 const twentyFourRegex = /^(?:[01]\d|2[0-3]):[0-5]\d$/
539 if (
540 val &&
541 val !== '__PARTIAL__' &&
542 !twentyFourRegex.test(val) &&
543 !(props.field.timeFormat === 'ampm' && ampmRegex.test(val))
544 ) {
545 handleInput('', true)
546 }
547 } else if (props.field.timeFieldType === 'dropdown') {
548 if (val !== '__PARTIAL__') {
549 const parsed = parseTimeString(val)
550 selectedHour.value = parsed.hours || ''
551 selectedMinute.value = parsed.minutes || ''
552 selectedDropdownMeridiem.value = parsed.amPm as 'AM' | 'PM' | ''
553 emitFromParts()
554 }
555 }
556 },
557 { immediate: true },
558 )
559 const fieldID = computed(() => {
560 return `ivyforms-field__time-input_${props.field.formId || ''}_${props.field.fieldIndex || ''}`
561 })
562 function disabledHours() {
563 const min = props.field.minTimeValue
564 const max = props.field.maxTimeValue
565 let minHour = TIME_CONSTANTS.MIN_HOUR_24
566 let maxHour = TIME_CONSTANTS.MAX_HOUR_24
567 if (min) minHour = Number(min.split(':')[0])
568 if (max) maxHour = Number(max.split(':')[0])
569 const arr = []
570 for (let h = TIME_CONSTANTS.MIN_HOUR_24; h < TIME_CONSTANTS.MIN_HOURS_IN_DAY; h++) {
571 if (h < minHour || h > maxHour) arr.push(h)
572 }
573 return arr
574 }
575 function disabledMinutes(hour: number) {
576 const min = props.field.minTimeValue
577 const max = props.field.maxTimeValue
578 let minHour = TIME_CONSTANTS.MIN_HOUR_24,
579 minMinute = TIME_CONSTANTS.MIN_MINUTE,
580 maxHour = TIME_CONSTANTS.MAX_HOUR_24,
581 maxMinute = TIME_CONSTANTS.MAX_MINUTE
582 if (min) {
583 const [h, m] = min.split(':').map(Number)
584 minHour = h
585 minMinute = m
586 }
587 if (max) {
588 const [h, m] = max.split(':').map(Number)
589 maxHour = h
590 maxMinute = m
591 }
592 const arr = []
593 for (let m = TIME_CONSTANTS.MIN_MINUTE; m < TIME_CONSTANTS.MINUTES_PER_HOUR; m++) {
594 if ((hour === minHour && m < minMinute) || (hour === maxHour && m > maxMinute)) arr.push(m)
595 }
596 return arr
597 }
598 </script>
599
600 <style lang="scss">
601 .ivyforms-field__time {
602 &__asterisk {
603 color: var(--map-status-error-fill-0);
604 }
605
606 &--is-readonly {
607 opacity: 0.7;
608 .el-select__wrapper,
609 .el-select__wrapper:hover,
610 .el-select__wrapper.is-focus,
611 .el-input__wrapper,
612 .el-input__wrapper:hover,
613 .el-input__wrapper.is-focus {
614 border-color: var(--map-base-dusk-stroke--2) !important;
615 box-shadow: none !important;
616 border: 1px solid var(--map-base-dusk-stroke--2) !important;
617 }
618 .el-select:has(> .is-disabled),
619 input[disabled]] {
620 cursor: not-allowed;
621 }
622 }
623 &__sublabel {
624 color: var(--map-base-text-0);
625 }
626 }
627 </style>
628