PluginProbe
The Innovative Form Builder – IvyForms / 0.9.1
The Innovative Form Builder – IvyForms v0.9.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 0.9.1, at frontend/src/views/public/fields/TimeFormField.vue

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