Skip to content

TimePicker Component Code

Dependencies

This component requires:

  • Vue 3 with Composition API
  • OMenu component 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>