UIPackage

Time Picker

Vue date-time
Edit on GitHub

Standalone time input — hours, minutes, optional seconds, and 12h/24h modes. Pairs with Date Picker for full datetime entry.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/time-picker.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/time-picker

Examples

Props

Name Type / Values Default Required
modelValue

Time value. HH:mm or HH:mm:ss depending on format. Empty = no selection.

string '' optional
use24Hour boolean false optional
use12Hours boolean false optional
minuteStep number 5 optional
hourStep number 1 optional
secondStep number 1 optional
minTime

24h HH:mm or HH:mm:ss.

string optional
maxTime

24h HH:mm or HH:mm:ss.

string optional
format TimeFormat 'HH:mm' optional
disabledHours () => number[] optional
disabledMinutes (selectedHour: number) => number[] optional
disabledSeconds (selectedHour: number, selectedMinute: number) => number[] optional
hideDisabledOptions boolean false optional
visible

Auto-scroll active rows into view when this becomes true.

boolean true optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

TimeParts
interface TimeParts {
  hour24: number
  minute: number
  second: number
}
TimePreset
interface TimePreset {
  label: string
  value: string
}
TimeRangePreset
interface TimeRangePreset {
  label: string
  value: [string, string]
}

Dependencies

Used by

Files (4)

  • app/components/ui/time-picker/TimeColumns.vue 10.3 kB
    <script setup lang="ts">
    import { computed, nextTick, ref, watch } from 'vue'
    import { ScrollArea } from '@/components/ui/scroll-area'
    
    export interface TimeParts {
      hour24: number
      minute: number
      second: number
    }
    
    export type TimeFormat = 'HH:mm' | 'HH:mm:ss' | 'hh:mm A'
    
    interface Props {
      /** Time value. HH:mm or HH:mm:ss depending on format. Empty = no selection. */
      modelValue?: string
      use24Hour?: boolean
      use12Hours?: boolean
      minuteStep?: number
      hourStep?: number
      secondStep?: number
      /** 24h HH:mm or HH:mm:ss. */
      minTime?: string
      /** 24h HH:mm or HH:mm:ss. */
      maxTime?: string
      format?: TimeFormat
      disabledHours?: () => number[]
      disabledMinutes?: (selectedHour: number) => number[]
      disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]
      hideDisabledOptions?: boolean
      /** Auto-scroll active rows into view when this becomes true. */
      visible?: boolean
    }
    
    const props = withDefaults(defineProps<Props>(), {
      modelValue: '',
      use24Hour: false,
      use12Hours: false,
      minuteStep: 5,
      hourStep: 1,
      secondStep: 1,
      format: 'HH:mm',
      hideDisabledOptions: false,
      visible: true,
    })
    
    const emits = defineEmits<{
      'update:modelValue': [value: string]
    }>()
    
    function parse(v: string): TimeParts | null {
      if (!v) return null
      const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(v.trim())
      if (!m) return null
      const h = Number(m[1])
      const min = Number(m[2])
      const s = m[3] ? Number(m[3]) : 0
      if (
        Number.isNaN(h) ||
        Number.isNaN(min) ||
        Number.isNaN(s) ||
        h < 0 ||
        h > 23 ||
        min < 0 ||
        min > 59 ||
        s < 0 ||
        s > 59
      )
        return null
      return { hour24: h, minute: min, second: s }
    }
    
    function format24(p: TimeParts) {
      if (props.format === 'HH:mm:ss') {
        return `${String(p.hour24).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}:${String(p.second).padStart(2, '0')}`
      }
      return `${String(p.hour24).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`
    }
    
    function toMinutes(p: TimeParts) {
      return p.hour24 * 60 + p.minute + p.second / 60
    }
    
    const parts = ref<TimeParts | null>(parse(props.modelValue))
    watch(
      () => props.modelValue,
      (v) => {
        parts.value = parse(v)
      },
    )
    
    const showSeconds = computed(() => props.format === 'HH:mm:ss')
    
    const effective12Hour = computed(() => {
      if (props.format === 'hh:mm A') return true
      if (props.use12Hours) return true
      return !props.use24Hour
    })
    
    const hour12 = computed(() => {
      if (!parts.value) return null
      const h = parts.value.hour24 % 12
      return h === 0 ? 12 : h
    })
    const period = computed(() => (parts.value && parts.value.hour24 >= 12 ? 'PM' : 'AM'))
    
    const minBound = computed(() => parse(props.minTime ?? '') ?? null)
    const maxBound = computed(() => parse(props.maxTime ?? '') ?? null)
    
    function withinBounds(p: TimeParts) {
      const m = toMinutes(p)
      if (minBound.value && m < toMinutes(minBound.value)) return false
      if (maxBound.value && m > toMinutes(maxBound.value)) return false
      return true
    }
    
    const disabledHoursSet = computed(() => {
      const arr = props.disabledHours ? props.disabledHours() : []
      return new Set(arr)
    })
    
    const disabledMinutesSet = computed(() => {
      const h = parts.value?.hour24 ?? 0
      const arr = props.disabledMinutes ? props.disabledMinutes(h) : []
      return new Set(arr)
    })
    
    const disabledSecondsSet = computed(() => {
      const h = parts.value?.hour24 ?? 0
      const m = parts.value?.minute ?? 0
      const arr = props.disabledSeconds ? props.disabledSeconds(h, m) : []
      return new Set(arr)
    })
    
    function isHourDisabledItem(h: number) {
      if (disabledHoursSet.value.has(h)) return true
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      if (effective12Hour.value) {
        const isPM = period.value === 'PM'
        return !withinBounds({ hour24: (h % 12) + (isPM ? 12 : 0), minute: cur.minute, second: cur.second })
      }
      return !withinBounds({ hour24: h, minute: cur.minute, second: cur.second })
    }
    
    function isMinuteDisabledItem(m: number) {
      if (disabledMinutesSet.value.has(m)) return true
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      return !withinBounds({ hour24: cur.hour24, minute: m, second: cur.second })
    }
    
    function isSecondDisabledItem(s: number) {
      if (disabledSecondsSet.value.has(s)) return true
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      return !withinBounds({ hour24: cur.hour24, minute: cur.minute, second: s })
    }
    
    const hours12List = computed(() => {
      const step = Math.max(1, props.hourStep)
      const list = Array.from({ length: 12 }, (_, i) => i + 1).filter((h) => (h - 1) % step === 0)
      if (!props.hideDisabledOptions) return list
      return list.filter((h) => !isHourDisabledItem(h))
    })
    
    const hours24List = computed(() => {
      const step = Math.max(1, props.hourStep)
      const list = Array.from({ length: 24 }, (_, i) => i).filter((h) => h % step === 0)
      if (!props.hideDisabledOptions) return list
      return list.filter((h) => !isHourDisabledItem(h))
    })
    
    const minutesList = computed(() => {
      const step = Math.max(1, Math.min(60, props.minuteStep))
      const list = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step)
      if (!props.hideDisabledOptions) return list
      return list.filter((m) => !isMinuteDisabledItem(m))
    })
    
    const secondsList = computed(() => {
      const step = Math.max(1, Math.min(60, props.secondStep))
      const list = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step)
      if (!props.hideDisabledOptions) return list
      return list.filter((s) => !isSecondDisabledItem(s))
    })
    
    function commit(p: TimeParts) {
      if (!withinBounds(p)) return
      parts.value = p
      emits('update:modelValue', format24(p))
    }
    
    function pickHour12(h: number) {
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      const isPM = period.value === 'PM'
      commit({ hour24: (h % 12) + (isPM ? 12 : 0), minute: cur.minute, second: cur.second })
    }
    
    function pickHour24(h: number) {
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      commit({ hour24: h, minute: cur.minute, second: cur.second })
    }
    
    function pickMinute(m: number) {
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      commit({ hour24: cur.hour24, minute: m, second: cur.second })
    }
    
    function pickSecond(s: number) {
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      commit({ hour24: cur.hour24, minute: cur.minute, second: s })
    }
    
    function pickPeriod(p: 'AM' | 'PM') {
      const cur = parts.value ?? { hour24: 0, minute: 0, second: 0 }
      const h12 = cur.hour24 % 12
      commit({ hour24: h12 + (p === 'PM' ? 12 : 0), minute: cur.minute, second: cur.second })
    }
    
    const hourCol = ref<HTMLElement | null>(null)
    const minCol = ref<HTMLElement | null>(null)
    const secCol = ref<HTMLElement | null>(null)
    watch(
      () => props.visible,
      (v) => {
        if (!v) return
        nextTick(() => {
          hourCol.value?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'center' })
          minCol.value?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'center' })
          secCol.value?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'center' })
        })
      },
      { immediate: true },
    )
    
    function isHourActive(h: number) {
      if (!parts.value) return false
      return effective12Hour.value ? hour12.value === h : parts.value.hour24 === h
    }
    
    function isHourDisabled(h: number) {
      if (!props.hideDisabledOptions) return isHourDisabledItem(h)
      return false
    }
    
    function isMinuteDisabled(m: number) {
      if (!props.hideDisabledOptions) return isMinuteDisabledItem(m)
      return false
    }
    
    function isSecondDisabled(s: number) {
      if (!props.hideDisabledOptions) return isSecondDisabledItem(s)
      return false
    }
    </script>
    
    <template>
      <div class="flex divide-x" data-uipkge data-slot="time-columns">
        <ScrollArea ref="hourCol" class="h-56">
          <div class="flex w-14 flex-col p-1">
            <button
              v-for="h in effective12Hour ? hours12List : hours24List"
              :key="h"
              type="button"
              :data-active="isHourActive(h)"
              :disabled="isHourDisabled(h)"
              class="hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent"
              :class="isHourActive(h) ? 'bg-primary text-primary-foreground hover:bg-primary' : ''"
              @click="effective12Hour ? pickHour12(h) : pickHour24(h)"
            >
              {{ String(h).padStart(2, '0') }}
            </button>
          </div>
        </ScrollArea>
    
        <ScrollArea ref="minCol" class="h-56">
          <div class="flex w-14 flex-col p-1">
            <button
              v-for="m in minutesList"
              :key="m"
              type="button"
              :data-active="parts?.minute === m"
              :disabled="isMinuteDisabled(m)"
              class="hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent"
              :class="parts?.minute === m ? 'bg-primary text-primary-foreground hover:bg-primary' : ''"
              @click="pickMinute(m)"
            >
              {{ String(m).padStart(2, '0') }}
            </button>
          </div>
        </ScrollArea>
    
        <ScrollArea v-if="showSeconds" ref="secCol" class="h-56">
          <div class="flex w-14 flex-col p-1">
            <button
              v-for="s in secondsList"
              :key="s"
              type="button"
              :data-active="parts?.second === s"
              :disabled="isSecondDisabled(s)"
              class="hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent"
              :class="parts?.second === s ? 'bg-primary text-primary-foreground hover:bg-primary' : ''"
              @click="pickSecond(s)"
            >
              {{ String(s).padStart(2, '0') }}
            </button>
          </div>
        </ScrollArea>
    
        <div v-if="effective12Hour" class="flex w-12 flex-col p-1">
          <button
            v-for="p in ['AM', 'PM']"
            :key="p"
            type="button"
            class="hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
            :class="period === p ? 'bg-primary text-primary-foreground hover:bg-primary' : ''"
            @click="pickPeriod(p as 'AM' | 'PM')"
          >
            {{ p }}
          </button>
        </div>
      </div>
    </template>
  • app/components/ui/time-picker/TimePicker.vue 7.2 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, ref, watch } from 'vue'
    import { Clock, X } from 'lucide-vue-next'
    import { Button } from '@/components/ui/button'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import TimeColumns, { type TimeFormat } from './TimeColumns.vue'
    import { cn } from '@/lib/utils'
    
    export interface TimePreset {
      label: string
      value: string
    }
    
    export type TimePickerSize = 'small' | 'middle' | 'large'
    export type TimePickerStatus = 'error' | 'warning'
    
    interface Props {
      /** Controlled value as 24h HH:mm or HH:mm:ss. Empty string = no selection. */
      modelValue?: string
      placeholder?: string
      disabled?: boolean
      readOnly?: boolean
      clearable?: boolean
      allowClear?: boolean
      minuteStep?: number
      hourStep?: number
      secondStep?: number
      /** Backward-compat: when true, render 24-hour selector. */
      use24Hour?: boolean
      /** When true, show AM/PM selector. */
      use12Hours?: boolean
      minTime?: string
      maxTime?: string
      format?: TimeFormat
      disabledHours?: () => number[]
      disabledMinutes?: (selectedHour: number) => number[]
      disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]
      hideDisabledOptions?: boolean
      presets?: TimePreset[]
      size?: TimePickerSize
      status?: TimePickerStatus
      triggerClass?: HTMLAttributes['class']
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      modelValue: '',
      placeholder: 'Pick a time',
      disabled: false,
      readOnly: false,
      clearable: true,
      allowClear: undefined,
      minuteStep: 5,
      hourStep: 1,
      secondStep: 1,
      use24Hour: false,
      use12Hours: false,
      format: 'HH:mm',
      hideDisabledOptions: false,
      presets: () => [],
      size: 'middle',
    })
    
    const emits = defineEmits<{
      'update:modelValue': [value: string]
    }>()
    
    const open = ref(false)
    
    const effectiveAllowClear = computed(() => {
      if (props.allowClear !== undefined) return props.allowClear
      return props.clearable
    })
    
    function parse(v: string) {
      if (!v) return null
      const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(v.trim())
      if (!m) return null
      return { hour24: Number(m[1]), minute: Number(m[2]), second: m[3] ? Number(m[3]) : 0 }
    }
    
    const parsed = computed(() => parse(props.modelValue))
    
    const effective12Hour = computed(() => {
      if (props.format === 'hh:mm A') return true
      if (props.use12Hours) return true
      return !props.use24Hour
    })
    
    function formatDisplay(h: number, m: number, s: number) {
      if (props.format === 'hh:mm A') {
        const h12 = h % 12 === 0 ? 12 : h % 12
        const period = h >= 12 ? 'PM' : 'AM'
        return `${String(h12).padStart(2, '0')}:${String(m).padStart(2, '0')} ${period}`
      }
      if (props.format === 'HH:mm:ss') {
        return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
      }
      return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
    }
    
    const display = computed(() => {
      if (!parsed.value) return ''
      const { hour24, minute, second } = parsed.value
      return formatDisplay(hour24, minute, second)
    })
    
    function emitTime(v: string) {
      emits('update:modelValue', v)
    }
    
    function pickNow() {
      if (props.readOnly) return
      const d = new Date()
      const sStep = Math.max(1, props.secondStep)
      const rawS = d.getSeconds()
      const snappedS = Math.round(rawS / sStep) * sStep
      const secCarry = Math.floor(snappedS / 60)
      const s = snappedS % 60
    
      const mStep = Math.max(1, props.minuteStep)
      const rawM = d.getMinutes() + secCarry
      const snappedM = Math.round(rawM / mStep) * mStep
      const minCarry = Math.floor(snappedM / 60)
      const min = snappedM % 60
    
      const h = d.getHours() + minCarry
      const hourStr = String(h).padStart(2, '0')
      const minStr = String(min).padStart(2, '0')
      const secStr = String(s).padStart(2, '0')
      emitTime(props.format === 'HH:mm:ss' ? `${hourStr}:${minStr}:${secStr}` : `${hourStr}:${minStr}`)
    }
    
    function applyPreset(preset: TimePreset) {
      if (props.readOnly) return
      emitTime(preset.value)
      open.value = false
    }
    
    function clear(event: Event) {
      event.stopPropagation()
      if (props.disabled || props.readOnly) return
      emitTime('')
    }
    
    const sizeClasses: Record<TimePickerSize, string> = {
      small: 'h-8 text-xs px-2.5 py-1',
      middle: 'h-9 text-sm px-3 py-1.5',
      large: 'h-11 text-base px-4 py-2',
    }
    
    const statusClasses: Record<string, string> = {
      error: 'border-destructive focus-visible:ring-destructive',
      warning: 'border-warning focus-visible:ring-warning',
    }
    
    const triggerClasses = computed(() =>
      cn(
        'min-w-[160px] justify-start gap-2 text-left font-normal',
        !parsed.value && 'text-muted-foreground',
        sizeClasses[props.size],
        props.status && statusClasses[props.status],
        props.triggerClass,
        props.class,
      ),
    )
    
    // Pulse the columns when reopen happens (passes via prop reactivity).
    const columnsVisible = ref(false)
    watch(open, (v) => {
      columnsVisible.value = v
    })
    </script>
    
    <template>
      <Popover v-model:open="open">
        <PopoverTrigger as-child>
          <Button
            type="button"
            variant="outline"
            :disabled="disabled"
            :class="triggerClasses"
            data-uipkge
            data-slot="time-picker"
          >
            <Clock class="size-4 shrink-0" />
            <span class="flex-1 truncate">{{ display || placeholder }}</span>
            <button
              v-if="effectiveAllowClear && parsed && !disabled && !readOnly"
              type="button"
              class="text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-5 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
              aria-label="Clear time"
              @click.stop="clear"
            >
              <X class="size-3.5" aria-hidden="true" />
            </button>
          </Button>
        </PopoverTrigger>
        <PopoverContent class="w-auto p-0" align="start">
          <div v-if="presets.length" class="flex flex-col gap-0.5 border-b p-2">
            <button
              v-for="p in presets"
              :key="p.label"
              type="button"
              class="hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
              @click="applyPreset(p)"
            >
              {{ p.label }}
            </button>
          </div>
          <div class="flex items-center justify-between border-b px-3 py-2">
            <span class="text-muted-foreground text-xs tracking-widest uppercase">Time</span>
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none"
              @click="pickNow"
            >
              Now
            </button>
          </div>
          <TimeColumns
            :model-value="modelValue"
            :use24-hour="use24Hour"
            :use12-hours="use12Hours"
            :minute-step="minuteStep"
            :hour-step="hourStep"
            :second-step="secondStep"
            :min-time="minTime"
            :max-time="maxTime"
            :format="format"
            :disabled-hours="disabledHours"
            :disabled-minutes="disabledMinutes"
            :disabled-seconds="disabledSeconds"
            :hide-disabled-options="hideDisabledOptions"
            :visible="columnsVisible"
            @update:model-value="emitTime"
          />
        </PopoverContent>
      </Popover>
    </template>
  • app/components/ui/time-picker/TimeRangePicker.vue 9.2 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, ref, watch } from 'vue'
    import { Clock, X } from 'lucide-vue-next'
    import { Button } from '@/components/ui/button'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import TimeColumns, { type TimeFormat } from './TimeColumns.vue'
    import { cn } from '@/lib/utils'
    
    export interface TimeRangePreset {
      label: string
      value: [string, string]
    }
    
    export type TimeRangePickerSize = 'small' | 'middle' | 'large'
    export type TimeRangePickerStatus = 'error' | 'warning'
    
    interface Props {
      /** Controlled value as [startTime, endTime] in 24h format. */
      modelValue?: [string, string] | null
      placeholder?: string
      disabled?: boolean
      readOnly?: boolean
      clearable?: boolean
      allowClear?: boolean
      minuteStep?: number
      hourStep?: number
      secondStep?: number
      use24Hour?: boolean
      use12Hours?: boolean
      minTime?: string
      maxTime?: string
      format?: TimeFormat
      disabledHours?: () => number[]
      disabledMinutes?: (selectedHour: number) => number[]
      disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]
      hideDisabledOptions?: boolean
      presets?: TimeRangePreset[]
      size?: TimeRangePickerSize
      status?: TimeRangePickerStatus
      triggerClass?: HTMLAttributes['class']
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      modelValue: null,
      placeholder: 'Pick a time range',
      disabled: false,
      readOnly: false,
      clearable: true,
      allowClear: undefined,
      minuteStep: 5,
      hourStep: 1,
      secondStep: 1,
      use24Hour: false,
      use12Hours: false,
      format: 'HH:mm',
      hideDisabledOptions: false,
      presets: () => [],
      size: 'middle',
    })
    
    const emits = defineEmits<{
      'update:modelValue': [value: [string, string] | null]
    }>()
    
    const open = ref(false)
    
    const effectiveAllowClear = computed(() => {
      if (props.allowClear !== undefined) return props.allowClear
      return props.clearable
    })
    
    function parse(v: string) {
      if (!v) return null
      const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(v.trim())
      if (!m) return null
      return { hour24: Number(m[1]), minute: Number(m[2]), second: m[3] ? Number(m[3]) : 0 }
    }
    
    const startValue = computed(() => props.modelValue?.[0] ?? '')
    const endValue = computed(() => props.modelValue?.[1] ?? '')
    
    const startParsed = computed(() => parse(startValue.value))
    const endParsed = computed(() => parse(endValue.value))
    
    const effective12Hour = computed(() => {
      if (props.format === 'hh:mm A') return true
      if (props.use12Hours) return true
      return !props.use24Hour
    })
    
    function formatDisplay(h: number, m: number, s: number) {
      if (props.format === 'hh:mm A') {
        const h12 = h % 12 === 0 ? 12 : h % 12
        const period = h >= 12 ? 'PM' : 'AM'
        return `${String(h12).padStart(2, '0')}:${String(m).padStart(2, '0')} ${period}`
      }
      if (props.format === 'HH:mm:ss') {
        return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
      }
      return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
    }
    
    const display = computed(() => {
      const hasStart = startParsed.value !== null
      const hasEnd = endParsed.value !== null
      if (!hasStart && !hasEnd) return ''
      const startStr = hasStart
        ? formatDisplay(startParsed.value!.hour24, startParsed.value!.minute, startParsed.value!.second)
        : ''
      const endStr = hasEnd ? formatDisplay(endParsed.value!.hour24, endParsed.value!.minute, endParsed.value!.second) : ''
      if (hasStart && hasEnd) return `${startStr} ~ ${endStr}`
      return startStr || endStr
    })
    
    function emitRange(start: string, end: string) {
      emits('update:modelValue', [start, end])
    }
    
    function emitStart(v: string) {
      emitRange(v, endValue.value || v)
    }
    
    function emitEnd(v: string) {
      emitRange(startValue.value || v, v)
    }
    
    function pickNow(which: 'start' | 'end') {
      if (props.readOnly) return
      const d = new Date()
      const sStep = Math.max(1, props.secondStep)
      const rawS = d.getSeconds()
      const snappedS = Math.round(rawS / sStep) * sStep
      const secCarry = Math.floor(snappedS / 60)
      const s = snappedS % 60
    
      const mStep = Math.max(1, props.minuteStep)
      const rawM = d.getMinutes() + secCarry
      const snappedM = Math.round(rawM / mStep) * mStep
      const minCarry = Math.floor(snappedM / 60)
      const min = snappedM % 60
    
      const h = d.getHours() + minCarry
      const hourStr = String(h).padStart(2, '0')
      const minStr = String(min).padStart(2, '0')
      const secStr = String(s).padStart(2, '0')
      const v = props.format === 'HH:mm:ss' ? `${hourStr}:${minStr}:${secStr}` : `${hourStr}:${minStr}`
      if (which === 'start') emitStart(v)
      else emitEnd(v)
    }
    
    function applyPreset(preset: TimeRangePreset) {
      if (props.readOnly) return
      emits('update:modelValue', preset.value)
      open.value = false
    }
    
    function clear(event: Event) {
      event.stopPropagation()
      if (props.disabled || props.readOnly) return
      emits('update:modelValue', null)
    }
    
    const sizeClasses: Record<TimeRangePickerSize, string> = {
      small: 'h-8 text-xs px-2.5 py-1',
      middle: 'h-9 text-sm px-3 py-1.5',
      large: 'h-11 text-base px-4 py-2',
    }
    
    const statusClasses: Record<string, string> = {
      error: 'border-destructive focus-visible:ring-destructive',
      warning: 'border-warning focus-visible:ring-warning',
    }
    
    const triggerClasses = computed(() =>
      cn(
        'min-w-[200px] justify-start gap-2 text-left font-normal',
        !display.value && 'text-muted-foreground',
        sizeClasses[props.size],
        props.status && statusClasses[props.status],
        props.triggerClass,
        props.class,
      ),
    )
    
    const columnsVisible = ref(false)
    watch(open, (v) => {
      columnsVisible.value = v
    })
    </script>
    
    <template>
      <Popover v-model:open="open">
        <PopoverTrigger as-child>
          <Button
            type="button"
            variant="outline"
            :disabled="disabled"
            :class="triggerClasses"
            data-uipkge
            data-slot="time-range-picker"
          >
            <Clock class="size-4 shrink-0" />
            <span class="flex-1 truncate">{{ display || placeholder }}</span>
            <button
              v-if="effectiveAllowClear && display && !disabled && !readOnly"
              type="button"
              class="text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-5 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
              aria-label="Clear time range"
              @click.stop="clear"
            >
              <X class="size-3.5" aria-hidden="true" />
            </button>
          </Button>
        </PopoverTrigger>
        <PopoverContent class="w-auto p-0" align="start">
          <div v-if="presets.length" class="flex flex-col gap-0.5 border-b p-2">
            <button
              v-for="p in presets"
              :key="p.label"
              type="button"
              class="hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
              @click="applyPreset(p)"
            >
              {{ p.label }}
            </button>
          </div>
          <div class="flex items-center justify-between border-b px-3 py-2">
            <span class="text-muted-foreground text-xs tracking-widest uppercase">Time Range</span>
            <div class="flex gap-2">
              <button
                type="button"
                class="text-muted-foreground hover:text-foreground focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none"
                @click="pickNow('start')"
              >
                Now (Start)
              </button>
              <button type="button" class="text-muted-foreground hover:text-foreground text-xs" @click="pickNow('end')">
                Now (End)
              </button>
            </div>
          </div>
          <div class="flex">
            <div class="flex flex-col">
              <div class="text-muted-foreground border-b px-3 py-1.5 text-center text-xs font-medium">Start</div>
              <TimeColumns
                :model-value="startValue"
                :use24-hour="use24Hour"
                :use12-hours="use12Hours"
                :minute-step="minuteStep"
                :hour-step="hourStep"
                :second-step="secondStep"
                :min-time="minTime"
                :max-time="maxTime"
                :format="format"
                :disabled-hours="disabledHours"
                :disabled-minutes="disabledMinutes"
                :disabled-seconds="disabledSeconds"
                :hide-disabled-options="hideDisabledOptions"
                :visible="columnsVisible"
                @update:model-value="emitStart"
              />
            </div>
            <div class="bg-border w-px" />
            <div class="flex flex-col">
              <div class="text-muted-foreground border-b px-3 py-1.5 text-center text-xs font-medium">End</div>
              <TimeColumns
                :model-value="endValue"
                :use24-hour="use24Hour"
                :use12-hours="use12Hours"
                :minute-step="minuteStep"
                :hour-step="hourStep"
                :second-step="secondStep"
                :min-time="minTime"
                :max-time="maxTime"
                :format="format"
                :disabled-hours="disabledHours"
                :disabled-minutes="disabledMinutes"
                :disabled-seconds="disabledSeconds"
                :hide-disabled-options="hideDisabledOptions"
                :visible="columnsVisible"
                @update:model-value="emitEnd"
              />
            </div>
          </div>
        </PopoverContent>
      </Popover>
    </template>
  • app/components/ui/time-picker/index.ts 0.2 kB
    export { default as TimePicker } from './TimePicker.vue'
    export { default as TimeRangePicker } from './TimeRangePicker.vue'
    export { default as TimeColumns } from './TimeColumns.vue'

Raw manifest: https://uipkge.dev/r/vue/time-picker.json