feat: refactor TimePicker and CalendarEventPanel for improved time selection and validation
This commit is contained in:
parent
032b0d3723
commit
0386df262e
@ -224,34 +224,19 @@
|
||||
v-if="!_event.isFullDay"
|
||||
class="max-w-[105px]"
|
||||
variant="outline"
|
||||
:value="_event.fromTime"
|
||||
v-model="_event.fromTime"
|
||||
:placeholder="__('Start Time')"
|
||||
@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
|
||||
class="max-w-[105px]"
|
||||
variant="outline"
|
||||
:value="_event.toTime"
|
||||
:customOptions="toOptions"
|
||||
v-model="_event.toTime"
|
||||
:options="toOptions"
|
||||
:placeholder="__('End Time')"
|
||||
placement="bottom-end"
|
||||
@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 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 EditIcon from '@/components/Icons/EditIcon.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 { usersStore } from '@/stores/users'
|
||||
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 { $dialog } = globalStore()
|
||||
@ -409,7 +402,6 @@ const peoples = computed({
|
||||
return _event.value.event_participants || []
|
||||
},
|
||||
set(list) {
|
||||
// Deduplicate by email while preserving first occurrence meta
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const a of list || []) {
|
||||
@ -723,7 +715,13 @@ function getTooltip(m) {
|
||||
function formatDuration(mins) {
|
||||
// For < 1 hour show minutes, else show hours (with decimal for 15/30/45 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)) {
|
||||
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
|
||||
}
|
||||
|
||||
@ -1,243 +1,495 @@
|
||||
<template>
|
||||
<Dropdown :options="options()">
|
||||
<template #default="{ open, togglePopover }">
|
||||
<slot
|
||||
v-bind="{
|
||||
emitUpdate,
|
||||
timeValue,
|
||||
isSelectedOrNearestOption,
|
||||
updateScroll,
|
||||
}"
|
||||
<Popover
|
||||
v-model:show="showOptions"
|
||||
transition="default"
|
||||
:placement="placement"
|
||||
@update:show="
|
||||
(v) => {
|
||||
if (!v) emit('close')
|
||||
}
|
||||
"
|
||||
>
|
||||
<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
|
||||
:variant="variant"
|
||||
class="text-sm"
|
||||
v-bind="$attrs"
|
||||
type="text"
|
||||
:value="timeValue"
|
||||
:placeholder="placeholder"
|
||||
@change="(e) => emitUpdate(e.target.value)"
|
||||
@keydown.enter.prevent="(e) => emitUpdate(e.target.value)"
|
||||
>
|
||||
<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 #suffix>
|
||||
<slot name="suffix" v-bind="{ togglePopover, isOpen }">
|
||||
<FeatherIcon
|
||||
name="chevron-down"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@mousedown.prevent="togglePopover"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</TextInput>
|
||||
</template>
|
||||
<template #body>
|
||||
<template #body="{ isOpen }">
|
||||
<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"
|
||||
:class="{
|
||||
'mt-2': ['bottom', 'left', 'right'].includes(placement),
|
||||
'ml-2': placement == 'right-start',
|
||||
}"
|
||||
v-show="isOpen"
|
||||
ref="panelRef"
|
||||
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"
|
||||
role="listbox"
|
||||
:aria-activedescendant="activeDescendantId"
|
||||
>
|
||||
<MenuItems class="p-1 focus-visible:outline-none" ref="menu">
|
||||
<MenuItem
|
||||
v-for="option in options()"
|
||||
:key="option.value"
|
||||
:data-value="option.value"
|
||||
>
|
||||
<slot
|
||||
name="menu-item"
|
||||
v-bind="{ option, isSelectedOrNearestOption, updateScroll }"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
option.isSelected()
|
||||
? 'bg-surface-gray-3 text-ink-gray-8'
|
||||
: 'text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8',
|
||||
'group flex h-7 w-full items-center rounded px-2 text-base ',
|
||||
]"
|
||||
@click="option.onClick"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</slot>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
<button
|
||||
v-for="(opt, idx) in displayedOptions"
|
||||
:key="opt.value"
|
||||
:data-value="opt.value"
|
||||
:data-index="idx"
|
||||
type="button"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-left"
|
||||
:class="buttonClasses(opt, idx)"
|
||||
@click="() => select(opt.value, autoClose)"
|
||||
@mouseenter="highlightIndex = idx"
|
||||
role="option"
|
||||
:id="optionId(idx)"
|
||||
:aria-selected="internalValue === opt.value"
|
||||
>
|
||||
<span class="truncate">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TextInput } from 'frappe-ui'
|
||||
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
||||
import { allTimeSlots } from '@/components/Calendar/utils'
|
||||
import { MenuItems, MenuItem } from '@headlessui/vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Popover, TextInput } from 'frappe-ui'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Select Time',
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom',
|
||||
},
|
||||
customOptions: {
|
||||
modelValue: { type: String, default: '' }, // Expect 24h format HH:MM, but will parse flexible input
|
||||
interval: { type: Number, default: 15 },
|
||||
options: {
|
||||
// Optional complete override of generated options (array of { value: 'HH:MM', label?: string })
|
||||
type: Array,
|
||||
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) => {
|
||||
emit('update:modelValue', convertTo24HourFormat(value))
|
||||
const panelRef = ref(null)
|
||||
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(() => {
|
||||
let time = props.value ? props.value : props.modelValue
|
||||
function minutesFromHHMM(str) {
|
||||
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 ''
|
||||
|
||||
if (time && time.length > 5) {
|
||||
time = time.substring(0, 5)
|
||||
const displayedOptions = computed(() => {
|
||||
if (props.options?.length) {
|
||||
return props.options.map((o) => ({
|
||||
value: normalize24(o.value),
|
||||
label: o.label || formatDisplay(normalize24(o.value)),
|
||||
}))
|
||||
}
|
||||
|
||||
// Try to find a matching option (value is always in 24h format HH:MM)
|
||||
const match = options().find((o) => o.value === time)
|
||||
if (match) return match.label.split(' (')[0]
|
||||
|
||||
// Fallback: format manually if the value isn't part of provided options
|
||||
const [hourStr, minute] = time.split(':')
|
||||
if (hourStr !== undefined && minute !== undefined) {
|
||||
const hourNum = parseInt(hourStr)
|
||||
if (!isNaN(hourNum)) {
|
||||
const ampm = hourNum >= 12 ? 'pm' : 'am'
|
||||
const formattedHour = hourNum % 12 || 12
|
||||
return `${formattedHour}:${minute} ${ampm}`
|
||||
}
|
||||
const out = []
|
||||
for (let m = 0; m < 1440; m += props.interval) {
|
||||
if (minMinutes.value != null && m < minMinutes.value) continue
|
||||
if (maxMinutes.value != null && m > maxMinutes.value) continue
|
||||
const hh = Math.floor(m / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const mm = (m % 60).toString().padStart(2, '0')
|
||||
const val = `${hh}:${mm}`
|
||||
out.push({
|
||||
value: val,
|
||||
label: formatDisplay(val),
|
||||
})
|
||||
}
|
||||
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(
|
||||
() => menu.value?.el,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
updateScroll(newValue)
|
||||
() => props.modelValue,
|
||||
(nv) => {
|
||||
if (nv && nv !== internalValue.value) {
|
||||
internalValue.value = normalize24(nv)
|
||||
displayValue.value = formatDisplay(internalValue.value)
|
||||
} else if (!nv) {
|
||||
internalValue.value = ''
|
||||
displayValue.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function convertTo24HourFormat(time) {
|
||||
if (time && time.length > 5) {
|
||||
time = time.trim().replace(' ', '')
|
||||
const ampm = time.slice(-2)
|
||||
time = time.slice(0, -2)
|
||||
let [hour, minute] = time.split(':')
|
||||
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 normalize24(raw) {
|
||||
if (!raw) return ''
|
||||
// already HH:MM 24h
|
||||
if (/^\d{2}:\d{2}$/.test(raw)) return raw
|
||||
const parsed = parseFlexibleTime(raw)
|
||||
return parsed.valid ? parsed.hh24 + ':' + parsed.mm : ''
|
||||
}
|
||||
|
||||
function isSelectedOrNearestOption() {
|
||||
const selectedTime = timeValue.value
|
||||
const selectedOption = options().find(
|
||||
(option) => option.label.split(' (')[0] === selectedTime,
|
||||
)
|
||||
function formatDisplay(val24) {
|
||||
if (!val24) return ''
|
||||
const [h, m] = val24.split(':').map((n) => parseInt(n))
|
||||
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) {
|
||||
return {
|
||||
...selectedOption,
|
||||
isNearest: false,
|
||||
}
|
||||
function parseFlexibleTime(input) {
|
||||
if (!input) return { valid: false }
|
||||
let s = input.trim().toLowerCase()
|
||||
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
|
||||
let time = convertTo24HourFormat(timeValue.value)
|
||||
const [hour, minute] = time.split(':')
|
||||
|
||||
// find nearest option where hour is same
|
||||
const nearestOption = options().find((option) => {
|
||||
const [optionHour] = option.value.split(':')
|
||||
return optionHour === hour
|
||||
// Returns index of nearest value (by minutes) using binary search on sorted list
|
||||
function findNearestIndex(targetMinutes, list) {
|
||||
if (!list.length) return -1
|
||||
const minutesArr = list.map((o) => {
|
||||
const [hh, mm] = o.value.split(':').map(Number)
|
||||
return hh * 60 + mm
|
||||
})
|
||||
|
||||
if (nearestOption) {
|
||||
return {
|
||||
...nearestOption,
|
||||
isNearest: true,
|
||||
}
|
||||
let lo = 0,
|
||||
hi = minutesArr.length - 1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
const val = minutesArr[mid]
|
||||
if (val === targetMinutes) return mid
|
||||
if (val < targetMinutes) lo = mid + 1
|
||||
else hi = mid - 1
|
||||
}
|
||||
|
||||
return null
|
||||
const candidates = []
|
||||
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) {
|
||||
const selectedOption = options().find(
|
||||
(option) => option.label === timeValue.value,
|
||||
)
|
||||
function isOutOfRange(totalMinutes) {
|
||||
if (minMinutes.value != null && totalMinutes < minMinutes.value) return true
|
||||
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) {
|
||||
selectedTimeObj = isSelectedOrNearestOption()
|
||||
function commitInput() {
|
||||
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) {
|
||||
const selectedElement = el.querySelector(
|
||||
`[data-value="${selectedTimeObj.value}"]`,
|
||||
)
|
||||
function select(val, close = props.autoClose) {
|
||||
internalValue.value = val
|
||||
displayValue.value = formatDisplay(val)
|
||||
emit('update:modelValue', val)
|
||||
if (close) {
|
||||
showOptions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
inline: 'start',
|
||||
block: 'start',
|
||||
})
|
||||
const selectedAndNearest = computed(() => {
|
||||
const list = displayedOptions.value
|
||||
if (!list.length) return { selected: null, nearest: null }
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user