Appearance
TimePicker Component Code
Dependencies
This component requires:
- Vue 3 with Composition API
OMenucomponent for the dropdown interface
Full Component Code
vue
<template>
<div class="time-picker-container">
<div v-if="label" class="label">{{ label }}</div>
<OMenu ref="menuRef" :close-on-outside-click="true" placement="bottom-start">
<template #trigger>
<div
class="time-input"
:class="{ disabled, focused: menuRef?.isOpen }"
tabindex="0"
>
<span class="time-value">{{ displayValue }}</span>
<span class="time-icon">🕐</span>
</div>
</template>
<template #content>
<div class="time-picker-content">
<!-- 12/24 Hour Toggle -->
<div class="format-toggle">
<button
@click="use12HourFormat = !use12HourFormat"
class="format-btn"
:class="{ active: use12HourFormat }"
>
{{ use12HourFormat ? '12H' : '24H' }}
</button>
</div>
<!-- Time Selectors -->
<div class="time-selectors">
<!-- Hours -->
<div class="time-column">
<div class="column-header">Hours</div>
<div class="time-list" ref="hoursRef">
<div
v-for="hour in availableHours"
:key="hour.value"
class="time-item"
:class="{ selected: hour.value === selectedHour }"
@click="selectHour(hour.value)"
>
{{ hour.display }}
</div>
</div>
</div>
<!-- Minutes -->
<div class="time-column">
<div class="column-header">Minutes</div>
<div class="time-list" ref="minutesRef">
<div
v-for="minute in availableMinutes"
:key="minute"
class="time-item"
:class="{ selected: minute === selectedMinute }"
@click="selectMinute(minute)"
>
{{ minute.toString().padStart(2, '0') }}
</div>
</div>
</div>
<!-- AM/PM for 12-hour format -->
<div v-if="use12HourFormat" class="time-column">
<div class="column-header">Period</div>
<div class="time-list">
<div
class="time-item"
:class="{ selected: selectedPeriod === 'AM' }"
@click="selectPeriod('AM')"
>
AM
</div>
<div
class="time-item"
:class="{ selected: selectedPeriod === 'PM' }"
@click="selectPeriod('PM')"
>
PM
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="quick-actions">
<button @click="selectNow" class="quick-btn">Now</button>
<button @click="clearTime" class="quick-btn">Clear</button>
</div>
</div>
</template>
</OMenu>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import OMenu from './OMenu.vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
label: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Select time'
},
disabled: {
type: Boolean,
default: false
},
use12HourFormat: {
type: Boolean,
default: true
},
minuteStep: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:modelValue', 'change'])
// Refs
const menuRef = ref(null)
const hoursRef = ref(null)
const minutesRef = ref(null)
const use12HourFormat = ref(props.use12HourFormat)
// Time state
const selectedHour = ref(12)
const selectedMinute = ref(0)
const selectedPeriod = ref('AM')
// Computed
const displayValue = computed(() => {
if (!props.modelValue) return props.placeholder
try {
const [hours, minutes] = props.modelValue.split(':')
const hour = parseInt(hours, 10)
const minute = parseInt(minutes, 10)
if (use12HourFormat.value) {
const period = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`
} else {
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
} catch (e) {
return props.modelValue
}
})
const availableHours = computed(() => {
if (use12HourFormat.value) {
return Array.from({ length: 12 }, (_, i) => ({
value: i === 0 ? 12 : i,
display: (i === 0 ? 12 : i).toString()
}))
} else {
return Array.from({ length: 24 }, (_, i) => ({
value: i,
display: i.toString().padStart(2, '0')
}))
}
})
const availableMinutes = computed(() => {
return Array.from({ length: Math.floor(60 / props.minuteStep) }, (_, i) => i * props.minuteStep)
})
// Methods
const parseTimeValue = (timeString) => {
if (!timeString) return
try {
const [hours, minutes] = timeString.split(':')
const hour = parseInt(hours, 10)
const minute = parseInt(minutes, 10)
if (use12HourFormat.value) {
selectedPeriod.value = hour >= 12 ? 'PM' : 'AM'
selectedHour.value = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
} else {
selectedHour.value = hour
}
selectedMinute.value = minute
} catch (e) {
console.error('Error parsing time:', e)
}
}
const formatTimeValue = () => {
let hour = selectedHour.value
if (use12HourFormat.value) {
if (selectedPeriod.value === 'PM' && hour !== 12) {
hour += 12
} else if (selectedPeriod.value === 'AM' && hour === 12) {
hour = 0
}
}
return `${hour.toString().padStart(2, '0')}:${selectedMinute.value.toString().padStart(2, '0')}`
}
const selectHour = (hour) => {
selectedHour.value = hour
emitValue()
}
const selectMinute = (minute) => {
selectedMinute.value = minute
emitValue()
}
const selectPeriod = (period) => {
selectedPeriod.value = period
emitValue()
}
const emitValue = () => {
const timeValue = formatTimeValue()
emit('update:modelValue', timeValue)
emit('change', timeValue)
}
const selectNow = () => {
const now = new Date()
const hour = now.getHours()
const minute = now.getMinutes()
if (use12HourFormat.value) {
selectedPeriod.value = hour >= 12 ? 'PM' : 'AM'
selectedHour.value = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
} else {
selectedHour.value = hour
}
selectedMinute.value = Math.floor(minute / props.minuteStep) * props.minuteStep
emitValue()
menuRef.value?.closeMenu()
}
const clearTime = () => {
emit('update:modelValue', '')
emit('change', '')
menuRef.value?.closeMenu()
}
// Watch for external changes
watch(() => props.modelValue, (newValue) => {
if (newValue) {
parseTimeValue(newValue)
}
}, { immediate: true })
watch(() => props.use12HourFormat, (newValue) => {
use12HourFormat.value = newValue
if (props.modelValue) {
parseTimeValue(props.modelValue)
}
}, { immediate: true })
</script>
<style scoped lang="scss">
.time-picker-container {
position: relative;
width: 100%;
.label {
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.time-input {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-height: 48px;
&:hover {
border-color: #007bff;
}
&.focused {
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
&.disabled {
background: #f5f5f5;
cursor: not-allowed;
opacity: 0.6;
}
.time-value {
flex: 1;
color: #333;
&:empty::before {
content: attr(placeholder);
color: #999;
}
}
.time-icon {
margin-left: 0.5rem;
font-size: 1.2rem;
}
}
}
.time-picker-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 1rem;
min-width: 280px;
.format-toggle {
margin-bottom: 1rem;
text-align: center;
.format-btn {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f8f9fa;
}
&.active {
background: #007bff;
color: white;
border-color: #007bff;
}
}
}
.time-selectors {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
.time-column {
flex: 1;
.column-header {
text-align: center;
font-size: 0.8rem;
font-weight: 600;
color: #666;
margin-bottom: 0.5rem;
padding: 0.25rem;
}
.time-list {
max-height: 150px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #f8f9fa;
.time-item {
padding: 0.5rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #e0e0e0;
&:last-child {
border-bottom: none;
}
&:hover {
background: #e9ecef;
}
&.selected {
background: #007bff;
color: white;
font-weight: 600;
}
}
}
}
}
.quick-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
.quick-btn {
padding: 0.5rem 0.75rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
&:hover {
background: #f8f9fa;
border-color: #007bff;
}
}
}
}
/* Custom scrollbar for time lists */
.time-list::-webkit-scrollbar {
width: 6px;
}
.time-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.time-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
&:hover {
background: #a8a8a8;
}
}
</style>