|
|
|
|
@@ -1,15 +1,36 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div ref="root" class="relative w-full">
|
|
|
|
|
<button
|
|
|
|
|
<div
|
|
|
|
|
ref="trigger"
|
|
|
|
|
type="button"
|
|
|
|
|
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500"
|
|
|
|
|
:disabled="props.disabled"
|
|
|
|
|
@click="toggleOpen"
|
|
|
|
|
class="w-full flex items-center rounded-md border border-neutral-300 bg-white px-2 text-sm text-neutral-900 focus-within:border-primary-500"
|
|
|
|
|
:class="props.disabled ? 'cursor-not-allowed bg-neutral-100 text-neutral-500' : ''"
|
|
|
|
|
>
|
|
|
|
|
{{ displayValue }}
|
|
|
|
|
<Icon name="mdi:chevron-down" class="self-center"/>
|
|
|
|
|
</button>
|
|
|
|
|
<input
|
|
|
|
|
ref="inputRef"
|
|
|
|
|
v-model="inputValue"
|
|
|
|
|
type="text"
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
:placeholder="placeholder"
|
|
|
|
|
:disabled="props.disabled"
|
|
|
|
|
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed"
|
|
|
|
|
@focus="openMenu"
|
|
|
|
|
@keydown.down.prevent="openMenuAndFocusFirst"
|
|
|
|
|
@keydown.enter.prevent="commitInput"
|
|
|
|
|
@keydown.esc.prevent="closeMenu"
|
|
|
|
|
@input="onInput($event)"
|
|
|
|
|
@blur="onInputBlur"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
tabindex="-1"
|
|
|
|
|
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed"
|
|
|
|
|
:disabled="props.disabled"
|
|
|
|
|
@mousedown.prevent
|
|
|
|
|
@click="toggleOpen"
|
|
|
|
|
>
|
|
|
|
|
<Icon name="mdi:chevron-down" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
<div
|
|
|
|
|
@@ -18,15 +39,11 @@
|
|
|
|
|
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
|
|
|
|
:style="menuStyle"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
|
|
|
|
@click="selectValue('')"
|
|
|
|
|
>
|
|
|
|
|
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
|
|
|
|
|
{{ placeholder }}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
v-for="slot in timeSlots"
|
|
|
|
|
v-for="slot in filteredTimeSlots"
|
|
|
|
|
:key="slot"
|
|
|
|
|
type="button"
|
|
|
|
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
|
|
|
|
@@ -34,6 +51,9 @@
|
|
|
|
|
>
|
|
|
|
|
{{ slot }}
|
|
|
|
|
</button>
|
|
|
|
|
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
|
|
|
|
|
Aucun résultat
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Teleport>
|
|
|
|
|
</template>
|
|
|
|
|
@@ -55,7 +75,9 @@ const emit = defineEmits<{
|
|
|
|
|
const root = ref<HTMLElement | null>(null)
|
|
|
|
|
const trigger = ref<HTMLElement | null>(null)
|
|
|
|
|
const menu = ref<HTMLElement | null>(null)
|
|
|
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
|
const isOpen = ref(false)
|
|
|
|
|
const inputValue = ref('')
|
|
|
|
|
const menuStyle = ref<Record<string, string>>({
|
|
|
|
|
top: '0px',
|
|
|
|
|
left: '0px',
|
|
|
|
|
@@ -73,7 +95,31 @@ const timeSlots = computed(() => {
|
|
|
|
|
return slots
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const displayValue = computed(() => props.modelValue || props.placeholder)
|
|
|
|
|
const filteredTimeSlots = computed(() => {
|
|
|
|
|
const query = inputValue.value.trim()
|
|
|
|
|
if (!query) return timeSlots.value
|
|
|
|
|
return timeSlots.value.filter((slot) => slot.includes(query))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const applyTimeMask = (value: string): string => {
|
|
|
|
|
const digits = value.replace(/\D/g, '').slice(0, 4)
|
|
|
|
|
if (digits.length <= 2) return digits
|
|
|
|
|
return `${digits.slice(0, 2)}:${digits.slice(2)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalizeTypedTime = (value: string): string | null => {
|
|
|
|
|
const trimmed = value.trim()
|
|
|
|
|
if (trimmed === '') return ''
|
|
|
|
|
|
|
|
|
|
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
|
|
|
|
|
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
|
|
|
|
|
if (!match) return null
|
|
|
|
|
const hours = Number(match[1])
|
|
|
|
|
const minutes = Number(match[2])
|
|
|
|
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
|
|
|
|
|
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
|
|
|
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateMenuPosition = () => {
|
|
|
|
|
const triggerEl = trigger.value
|
|
|
|
|
@@ -103,10 +149,57 @@ const toggleOpen = () => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openMenu = () => {
|
|
|
|
|
if (props.disabled) return
|
|
|
|
|
if (!isOpen.value) {
|
|
|
|
|
isOpen.value = true
|
|
|
|
|
nextTick(updateMenuPosition)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openMenuAndFocusFirst = () => {
|
|
|
|
|
openMenu()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const closeMenu = () => {
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const commitInput = () => {
|
|
|
|
|
const normalized = normalizeTypedTime(inputValue.value)
|
|
|
|
|
if (normalized === null) {
|
|
|
|
|
inputValue.value = props.modelValue
|
|
|
|
|
closeMenu()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
emit('update:modelValue', normalized)
|
|
|
|
|
inputValue.value = normalized
|
|
|
|
|
closeMenu()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onInput = (event: Event) => {
|
|
|
|
|
const target = event.target as HTMLInputElement
|
|
|
|
|
const masked = applyTimeMask(target.value)
|
|
|
|
|
if (masked !== inputValue.value) {
|
|
|
|
|
inputValue.value = masked
|
|
|
|
|
}
|
|
|
|
|
openMenu()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onInputBlur = () => {
|
|
|
|
|
// Laisse le temps au click menu de passer avant fermeture.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (menu.value?.contains(document.activeElement)) return
|
|
|
|
|
commitInput()
|
|
|
|
|
}, 50)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectValue = (value: string) => {
|
|
|
|
|
if (props.disabled) return
|
|
|
|
|
emit('update:modelValue', value)
|
|
|
|
|
inputValue.value = value
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
nextTick(() => inputRef.value?.focus())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onDocumentClick = (event: MouseEvent) => {
|
|
|
|
|
@@ -139,6 +232,14 @@ watch(() => props.disabled, (disabled) => {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.modelValue,
|
|
|
|
|
(value) => {
|
|
|
|
|
inputValue.value = value
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
document.addEventListener('click', onDocumentClick)
|
|
|
|
|
})
|
|
|
|
|
|