feat: refactor TimePicker and CalendarEventPanel for improved time selection and validation

This commit is contained in:
Shariq Ansari 2025-08-27 20:36:04 +05:30
parent 032b0d3723
commit 0386df262e
2 changed files with 471 additions and 221 deletions

View File

@ -224,34 +224,19 @@
v-if="!_event.isFullDay" v-if="!_event.isFullDay"
class="max-w-[105px]" class="max-w-[105px]"
variant="outline" variant="outline"
:value="_event.fromTime" v-model="_event.fromTime"
:placeholder="__('Start Time')" :placeholder="__('Start Time')"
@update:modelValue="(time) => updateTime(time, true)" @update:modelValue="(time) => updateTime(time, true)"
> />
<template #suffix="{ togglePopover }">
<FeatherIcon
name="chevron-down"
class="h-4 w-4 cursor-pointer"
@click="togglePopover"
/>
</template>
</TimePicker>
<TimePicker <TimePicker
class="max-w-[105px]" class="max-w-[105px]"
variant="outline" variant="outline"
:value="_event.toTime" v-model="_event.toTime"
:customOptions="toOptions" :options="toOptions"
:placeholder="__('End Time')" :placeholder="__('End Time')"
placement="bottom-end"
@update:modelValue="(time) => updateTime(time)" @update:modelValue="(time) => updateTime(time)"
> />
<template #suffix="{ togglePopover }">
<FeatherIcon
name="chevron-down"
class="h-4 w-4 cursor-pointer"
@click="togglePopover"
/>
</template>
</TimePicker>
</div> </div>
</div> </div>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" /> <div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
@ -363,7 +348,7 @@ import DealsIcon from '@/components/Icons/DealsIcon.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import EditIcon from '@/components/Icons/EditIcon.vue' import EditIcon from '@/components/Icons/EditIcon.vue'
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue' import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
import TimePicker from './TimePicker.vue' import TimePicker from '@/components/Calendar/TimePicker.vue'
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { getFormat, validateEmail } from '@/utils' import { getFormat, validateEmail } from '@/utils'
@ -394,7 +379,15 @@ const props = defineProps({
}, },
}) })
const emit = defineEmits(['save', 'edit', 'delete', 'details', 'close']) const emit = defineEmits([
'save',
'edit',
'delete',
'details',
'close',
'sync',
'duplicate',
])
const router = useRouter() const router = useRouter()
const { $dialog } = globalStore() const { $dialog } = globalStore()
@ -409,7 +402,6 @@ const peoples = computed({
return _event.value.event_participants || [] return _event.value.event_participants || []
}, },
set(list) { set(list) {
// Deduplicate by email while preserving first occurrence meta
const seen = new Set() const seen = new Set()
const out = [] const out = []
for (const a of list || []) { for (const a of list || []) {
@ -723,7 +715,13 @@ function getTooltip(m) {
function formatDuration(mins) { function formatDuration(mins) {
// For < 1 hour show minutes, else show hours (with decimal for 15/30/45 mins) // For < 1 hour show minutes, else show hours (with decimal for 15/30/45 mins)
if (mins < 60) return __('{0} mins', [mins]) if (mins < 60) return __('{0} mins', [mins])
const hours = mins / 60 let hours = mins / 60
// keep hours decimal to 2 only if decimal is not 0
if (hours % 1 !== 0) {
hours = hours.toFixed(2)
}
if (Number.isInteger(hours)) { if (Number.isInteger(hours)) {
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours]) return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
} }

View File

@ -1,243 +1,495 @@
<template> <template>
<Dropdown :options="options()"> <Popover
<template #default="{ open, togglePopover }"> v-model:show="showOptions"
<slot transition="default"
v-bind="{ :placement="placement"
emitUpdate, @update:show="
timeValue, (v) => {
isSelectedOrNearestOption, if (!v) emit('close')
updateScroll, }
}" "
>
<template #target="{ togglePopover, isOpen }">
<TextInput
ref="inputRef"
v-model="displayValue"
:variant="variant"
type="text"
class="text-sm w-full cursor-text"
:placeholder="placeholder"
:disabled="disabled"
@focus="onFocus(isOpen, togglePopover)"
@click="onClickInput(isOpen, togglePopover)"
@keydown.enter.prevent="onEnter"
@blur="commitInput"
@keydown.down.prevent="onArrowDown(togglePopover, isOpen)"
@keydown.up.prevent="onArrowUp(togglePopover, isOpen)"
@keydown.esc.prevent="onEscape"
> >
<TextInput <template #suffix>
:variant="variant" <slot name="suffix" v-bind="{ togglePopover, isOpen }">
class="text-sm" <FeatherIcon
v-bind="$attrs" name="chevron-down"
type="text" class="h-4 w-4 cursor-pointer"
:value="timeValue" @mousedown.prevent="togglePopover"
:placeholder="placeholder" />
@change="(e) => emitUpdate(e.target.value)" </slot>
@keydown.enter.prevent="(e) => emitUpdate(e.target.value)" </template>
> </TextInput>
<template #prefix v-if="$slots.prefix">
<slot name="prefix" />
</template>
<template #suffix v-if="$slots.suffix">
<slot name="suffix" v-bind="{ togglePopover }" />
</template>
</TextInput>
</slot>
</template> </template>
<template #body> <template #body="{ isOpen }">
<div <div
class="mt-2 min-w-40 max-h-72 overflow-hidden overflow-y-auto divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5" v-show="isOpen"
:class="{ ref="panelRef"
'mt-2': ['bottom', 'left', 'right'].includes(placement), class="mt-2 max-h-48 w-44 overflow-y-auto rounded-lg bg-surface-modal p-1 text-base shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
'ml-2': placement == 'right-start', role="listbox"
}" :aria-activedescendant="activeDescendantId"
> >
<MenuItems class="p-1 focus-visible:outline-none" ref="menu"> <button
<MenuItem v-for="(opt, idx) in displayedOptions"
v-for="option in options()" :key="opt.value"
:key="option.value" :data-value="opt.value"
:data-value="option.value" :data-index="idx"
> type="button"
<slot class="group flex h-7 w-full items-center rounded px-2 text-left"
name="menu-item" :class="buttonClasses(opt, idx)"
v-bind="{ option, isSelectedOrNearestOption, updateScroll }" @click="() => select(opt.value, autoClose)"
> @mouseenter="highlightIndex = idx"
<button role="option"
:class="[ :id="optionId(idx)"
option.isSelected() :aria-selected="internalValue === opt.value"
? 'bg-surface-gray-3 text-ink-gray-8' >
: 'text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8', <span class="truncate">{{ opt.label }}</span>
'group flex h-7 w-full items-center rounded px-2 text-base ', </button>
]"
@click="option.onClick"
>
{{ option.label }}
</button>
</slot>
</MenuItem>
</MenuItems>
</div> </div>
</template> </template>
</Dropdown> </Popover>
</template> </template>
<script setup> <script setup>
import { TextInput } from 'frappe-ui' import { Popover, TextInput } from 'frappe-ui'
import Dropdown from '@/components/frappe-ui/Dropdown.vue' import { ref, computed, watch, nextTick } from 'vue'
import { allTimeSlots } from '@/components/Calendar/utils'
import { MenuItems, MenuItem } from '@headlessui/vue'
import { ref, computed, watch } from 'vue'
const props = defineProps({ const props = defineProps({
value: { modelValue: { type: String, default: '' }, // Expect 24h format HH:MM, but will parse flexible input
type: String, interval: { type: Number, default: 15 },
default: '', options: {
}, // Optional complete override of generated options (array of { value: 'HH:MM', label?: string })
modelValue: {
type: String,
default: '',
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: 'Select Time',
},
placement: {
type: String,
default: 'bottom',
},
customOptions: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
placement: {type: String, default: 'bottom-start'},
placeholder: { type: String, default: 'Select time' },
variant: { type: String, default: 'outline' },
allowCustom: { type: Boolean, default: true },
autoClose: { type: Boolean, default: true },
use12Hour: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
scrollMode: { type: String, default: 'center' }, // center | start | nearest
amLabel: { type: String, default: 'am' },
pmLabel: { type: String, default: 'pm' },
minTime: { type: String, default: '' }, // inclusive HH:MM 24h
maxTime: { type: String, default: '' }, // inclusive HH:MM 24h
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits([
'update:modelValue',
'input-invalid',
'invalid-change',
'open',
'close',
])
const emitUpdate = (value) => { const panelRef = ref(null)
emit('update:modelValue', convertTo24HourFormat(value)) const showOptions = ref(false)
const highlightIndex = ref(-1)
const hasSelectedOnFirstClick = ref(false)
const isTyping = ref(false)
let navUpdating = false
let invalidState = false
const inputRef = ref(null)
const internalValue = ref(props.modelValue) // always normalized 24h HH:MM or ''
const displayValue = ref(formatDisplay(internalValue.value))
const uid = Math.random().toString(36).slice(2, 9)
const activeDescendantId = computed(() =>
highlightIndex.value > -1 ? optionId(highlightIndex.value) : null,
)
function optionId(idx) {
return `tp-${uid}-${idx}`
} }
const timeValue = computed(() => { function minutesFromHHMM(str) {
let time = props.value ? props.value : props.modelValue if (!str) return null
if (!/^\d{2}:\d{2}$/.test(str)) return null
const [h, m] = str.split(':').map(Number)
if (h > 23 || m > 59) return null
return h * 60 + m
}
const minMinutes = computed(() => minutesFromHHMM(props.minTime))
const maxMinutes = computed(() => minutesFromHHMM(props.maxTime))
if (!time) return '' const displayedOptions = computed(() => {
if (props.options?.length) {
if (time && time.length > 5) { return props.options.map((o) => ({
time = time.substring(0, 5) value: normalize24(o.value),
label: o.label || formatDisplay(normalize24(o.value)),
}))
} }
const out = []
// Try to find a matching option (value is always in 24h format HH:MM) for (let m = 0; m < 1440; m += props.interval) {
const match = options().find((o) => o.value === time) if (minMinutes.value != null && m < minMinutes.value) continue
if (match) return match.label.split(' (')[0] if (maxMinutes.value != null && m > maxMinutes.value) continue
const hh = Math.floor(m / 60)
// Fallback: format manually if the value isn't part of provided options .toString()
const [hourStr, minute] = time.split(':') .padStart(2, '0')
if (hourStr !== undefined && minute !== undefined) { const mm = (m % 60).toString().padStart(2, '0')
const hourNum = parseInt(hourStr) const val = `${hh}:${mm}`
if (!isNaN(hourNum)) { out.push({
const ampm = hourNum >= 12 ? 'pm' : 'am' value: val,
const formattedHour = hourNum % 12 || 12 label: formatDisplay(val),
return `${formattedHour}:${minute} ${ampm}` })
}
} }
return time return out
}) })
const options = () => {
let timeOptions = []
const _options = props.customOptions.length
? props.customOptions
: allTimeSlots()
for (const option of _options) {
timeOptions.push(timeObj(option.label, option.value))
}
return timeOptions
}
function timeObj(label, value) {
return {
label,
value,
onClick: () => emitUpdate(value),
isSelected: () => {
let isSelected = isSelectedOrNearestOption()
return isSelected?.value === value && !isSelected?.isNearest
},
}
}
const menu = ref(null)
watch( watch(
() => menu.value?.el, () => props.modelValue,
(newValue) => { (nv) => {
if (newValue) { if (nv && nv !== internalValue.value) {
updateScroll(newValue) internalValue.value = normalize24(nv)
displayValue.value = formatDisplay(internalValue.value)
} else if (!nv) {
internalValue.value = ''
displayValue.value = ''
} }
}, },
) )
function convertTo24HourFormat(time) { function normalize24(raw) {
if (time && time.length > 5) { if (!raw) return ''
time = time.trim().replace(' ', '') // already HH:MM 24h
const ampm = time.slice(-2) if (/^\d{2}:\d{2}$/.test(raw)) return raw
time = time.slice(0, -2) const parsed = parseFlexibleTime(raw)
let [hour, minute] = time.split(':') return parsed.valid ? parsed.hh24 + ':' + parsed.mm : ''
if (ampm === 'pm' && parseInt(hour) < 12) {
hour = parseInt(hour) + 12
} else if (ampm === 'am' && hour == 12) {
hour = 0
}
time = `${hour.toString().padStart(2, '0')}:${minute}`
}
return time
} }
function isSelectedOrNearestOption() { function formatDisplay(val24) {
const selectedTime = timeValue.value if (!val24) return ''
const selectedOption = options().find( const [h, m] = val24.split(':').map((n) => parseInt(n))
(option) => option.label.split(' (')[0] === selectedTime, if (!props.use12Hour)
) return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`
const am = h < 12
const hour12 = h % 12 === 0 ? 12 : h % 12
return `${hour12}:${m.toString().padStart(2, '0')} ${am ? props.amLabel : props.pmLabel}`
}
if (selectedOption) { function parseFlexibleTime(input) {
return { if (!input) return { valid: false }
...selectedOption, let s = input.trim().toLowerCase()
isNearest: false, s = s.replace(/\./g, '') // remove periods like a.m.
} // Insert space before am/pm if missing
s = s.replace(/(\d)(am|pm)$/, '$1 $2')
const re = /^(\d{1,2})(:?)(\d{0,2})\s*([ap]m)?$/
const m = s.match(re)
if (!m) return { valid: false }
let [, hhStr, colon, mmStr, ap] = m
let hh = parseInt(hhStr)
if (isNaN(hh) || hh < 0 || hh > 23) return { valid: false }
let mm = mmStr ? parseInt(mmStr) : 0
if (colon && !mmStr) mm = 0
if (isNaN(mm) || mm < 0 || mm > 59) return { valid: false }
if (ap) {
if (hh === 12 && ap === 'am') hh = 0
else if (hh < 12 && ap === 'pm') hh += 12
} }
return {
valid: true,
hh24: hh.toString().padStart(2, '0'),
mm: mm.toString().padStart(2, '0'),
total: hh * 60 + mm,
}
}
// remove hour from timeValue // Returns index of nearest value (by minutes) using binary search on sorted list
let time = convertTo24HourFormat(timeValue.value) function findNearestIndex(targetMinutes, list) {
const [hour, minute] = time.split(':') if (!list.length) return -1
const minutesArr = list.map((o) => {
// find nearest option where hour is same const [hh, mm] = o.value.split(':').map(Number)
const nearestOption = options().find((option) => { return hh * 60 + mm
const [optionHour] = option.value.split(':')
return optionHour === hour
}) })
let lo = 0,
if (nearestOption) { hi = minutesArr.length - 1
return { while (lo <= hi) {
...nearestOption, const mid = (lo + hi) >> 1
isNearest: true, const val = minutesArr[mid]
} if (val === targetMinutes) return mid
if (val < targetMinutes) lo = mid + 1
else hi = mid - 1
} }
const candidates = []
return null if (lo < minutesArr.length) candidates.push(lo)
if (lo - 1 >= 0) candidates.push(lo - 1)
if (!candidates.length) return -1
return candidates.sort(
(a, b) =>
Math.abs(minutesArr[a] - targetMinutes) -
Math.abs(minutesArr[b] - targetMinutes),
)[0]
} }
function updateScroll(el) { function isOutOfRange(totalMinutes) {
const selectedOption = options().find( if (minMinutes.value != null && totalMinutes < minMinutes.value) return true
(option) => option.label === timeValue.value, if (maxMinutes.value != null && totalMinutes > maxMinutes.value) return true
) return false
}
let selectedTimeObj = selectedOption ? { ...selectedOption } : null function applyValue(val24) {
internalValue.value = val24
displayValue.value = formatDisplay(val24)
emit('update:modelValue', val24)
setInvalid(false)
}
if (!selectedTimeObj) { function commitInput() {
selectedTimeObj = isSelectedOrNearestOption() const raw = displayValue.value
const parsed = parseFlexibleTime(raw)
if (!raw) {
internalValue.value = ''
emit('update:modelValue', '')
setInvalid(false)
return
} }
if (!parsed.valid) {
emit('input-invalid', raw)
setInvalid(true)
return
}
if (isOutOfRange(parsed.total)) {
emit('input-invalid', raw)
setInvalid(true)
return
}
const normalized = `${parsed.hh24}:${parsed.mm}`
// Snap if custom disallowed
if (
!props.allowCustom &&
!displayedOptions.value.some((o) => o.value === normalized)
) {
const nearestIdx = findNearestIndex(parsed.total, displayedOptions.value)
if (nearestIdx > -1)
return applyValue(displayedOptions.value[nearestIdx].value)
}
applyValue(normalized)
}
if (selectedTimeObj) { function select(val, close = props.autoClose) {
const selectedElement = el.querySelector( internalValue.value = val
`[data-value="${selectedTimeObj.value}"]`, displayValue.value = formatDisplay(val)
) emit('update:modelValue', val)
if (close) {
showOptions.value = false
}
}
if (selectedElement) { const selectedAndNearest = computed(() => {
selectedElement.scrollIntoView({ const list = displayedOptions.value
inline: 'start', if (!list.length) return { selected: null, nearest: null }
block: 'start', const parsedTyped = parseFlexibleTime(displayValue.value)
}) const candidate =
isTyping.value && parsedTyped.valid
? `${parsedTyped.hh24}:${parsedTyped.mm}`
: internalValue.value || null
if (!candidate) return { selected: null, nearest: null }
const selected = list.find((o) => o.value === candidate) || null
if (selected) return { selected, nearest: null }
const parsed = parseFlexibleTime(candidate)
if (!parsed.valid) return { selected: null, nearest: null }
const idx = findNearestIndex(parsed.total, list)
return { selected: null, nearest: idx > -1 ? list[idx] : null }
})
function buttonClasses(opt, idx) {
if (idx === highlightIndex.value) return 'bg-surface-gray-3 text-ink-gray-8'
const { selected, nearest } = selectedAndNearest.value
if (isTyping.value && !selected) {
if (nearest && nearest.value === opt.value)
return 'text-ink-gray-7 italic bg-surface-gray-2'
return 'text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8'
}
if (selected && selected.value === opt.value)
return 'bg-surface-gray-3 text-ink-gray-8'
if (nearest && nearest.value === opt.value)
return 'text-ink-gray-7 italic bg-surface-gray-2'
return 'text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8'
}
watch(
() => displayedOptions.value,
() => scheduleScroll(),
)
function scheduleScroll() {
nextTick(() => {
if (!panelRef.value) return
let targetEl = null
if (highlightIndex.value > -1) {
targetEl = panelRef.value.querySelector(
`[data-index="${highlightIndex.value}"]`,
)
} else {
const { selected, nearest } = selectedAndNearest.value
const target = selected || nearest
if (target)
targetEl = panelRef.value.querySelector(
`[data-value="${target.value}"]`,
)
} }
if (!targetEl) return
const el = targetEl
if (el)
el.scrollIntoView({
block:
props.scrollMode === 'center'
? 'center'
: props.scrollMode === 'start'
? 'start'
: 'nearest',
})
})
}
watch(showOptions, (open) => {
if (open) {
emit('open')
initHighlight()
scheduleScroll()
}
})
watch(
() => displayValue.value,
() => {
if (navUpdating) return
if (showOptions.value) scheduleScroll()
isTyping.value = true
highlightIndex.value = -1
},
)
function initHighlight() {
// set highlight to selected or nearest
const { selected, nearest } = selectedAndNearest.value
const target = selected || nearest
if (!target) {
highlightIndex.value = -1
return
}
const idx = displayedOptions.value.findIndex((o) => o.value === target.value)
highlightIndex.value = idx
}
function moveHighlight(delta) {
const list = displayedOptions.value
if (!list.length) return
if (highlightIndex.value === -1) initHighlight()
else
highlightIndex.value =
(highlightIndex.value + delta + list.length) % list.length
const opt = list[highlightIndex.value]
if (opt) {
navUpdating = true
internalValue.value = opt.value
displayValue.value = formatDisplay(opt.value)
emit('update:modelValue', opt.value)
nextTick(() => {
navUpdating = false
})
}
isTyping.value = false
scheduleScroll()
}
function onArrowDown(togglePopover, isOpen) {
if (!isOpen) togglePopover()
else moveHighlight(1)
}
function onArrowUp(togglePopover, isOpen) {
if (!isOpen) togglePopover()
else moveHighlight(-1)
}
function onEnter() {
if (!showOptions.value) {
commitInput()
blurInput()
return
}
const parsed = parseFlexibleTime(displayValue.value)
const normalized = parsed.valid ? `${parsed.hh24}:${parsed.mm}` : null
const exists = normalized
? displayedOptions.value.some((o) => o.value === normalized)
: false
if (parsed.valid && (!exists || isTyping.value)) {
commitInput()
if (props.autoClose) showOptions.value = false
blurInput()
return
}
if (highlightIndex.value > -1) {
const opt = displayedOptions.value[highlightIndex.value]
if (opt) select(opt.value, true)
} else {
commitInput()
if (props.autoClose) showOptions.value = false
}
blurInput()
}
function onClickInput(isOpen, togglePopover) {
if (!isOpen) {
togglePopover()
}
selectAll()
}
function onFocus() {
if (!hasSelectedOnFirstClick.value) selectAll()
}
function selectAll() {
nextTick(() => {
const el = inputRef.value?.el || inputRef.value
if (el && el.querySelector) {
const input = el.querySelector('input') || el
if (input?.select) input.select()
} else if (el?.select) {
el.select()
}
hasSelectedOnFirstClick.value = true
})
}
function blurInput() {
nextTick(() => {
const el = inputRef.value?.el || inputRef.value
if (el && el.querySelector) {
const input = el.querySelector('input') || el
input?.blur?.()
} else if (el?.blur) {
el.blur()
}
})
}
function onEscape() {
showOptions.value = false
blurInput()
}
function setInvalid(val) {
if (invalidState !== val) {
invalidState = val
emit('invalid-change', val)
} }
} }
</script> </script>