
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
time-pickeruiStandalone time input — hours, minutes, optional seconds, and 12h/24h modes. Pairs with Date Picker for full datetime entry.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/time-picker.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/time-picker.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/time-picker.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/time-picker.jsonnpx shadcn-vue@latest add @uipkge/time-pickerInstalls to:app/components/ui/time-picker/| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValueTime 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 |
minTime24h HH:mm or HH:mm:ss. | string | — | optional |
maxTime24h HH:mm or HH:mm:ss. | string | — | optional |
format | 'HH:mm''HH:mm:ss''hh:mm A' | 'HH:mm' | optional |
disabledHours | () => number[] | — | optional |
disabledMinutes | (selectedHour: number) => number[] | — | optional |
disabledSeconds | (selectedHour: number, selectedMinute: number) => number[] | — | optional |
hideDisabledOptions | boolean | false | optional |
visibleAuto-scroll active rows into view when this becomes true. | boolean | true | optional |
Type aliases from this item's source — use them to shape the data you pass in.
TimePartsinterface TimeParts {
hour24: number
minute: number
second: number
}TimePresetinterface TimePreset {
label: string
value: string
}TimeRangePresetinterface TimeRangePreset {
label: string
value: [string, string]
}<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')
// Default to 24h columns. Opt into 12h via format="hh:mm A" or use12Hours.
// (use24Hour remains as an explicit 24h force for API compat; it no longer
// inverts the default when false — that made HH:mm demos show AM/PM by accident.)
const effective12Hour = computed(() => {
if (props.format === 'hh:mm A') return true
if (props.use12Hours) return true
if (props.use24Hour) return false
return false
})
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 })
}
// ScrollArea is a component; resolve to the DOM root before querySelector.
const hourCol = ref<{ $el?: HTMLElement } | HTMLElement | null>(null)
const minCol = ref<{ $el?: HTMLElement } | HTMLElement | null>(null)
const secCol = ref<{ $el?: HTMLElement } | HTMLElement | null>(null)
function resolveEl(node: { $el?: HTMLElement } | HTMLElement | null | undefined): HTMLElement | null {
if (!node) return null
if (node instanceof HTMLElement) return node
const el = node.$el
return el instanceof HTMLElement ? el : null
}
function scrollActiveIntoView() {
for (const col of [hourCol.value, minCol.value, secCol.value]) {
resolveEl(col)?.querySelector<HTMLElement>('[data-active="true"]')?.scrollIntoView({ block: 'center' })
}
}
watch(
() => props.visible,
(v) => {
if (!v) return
nextTick(scrollActiveIntoView)
},
{ 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>
<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))
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) % 24
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-40 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" aria-hidden="true" />
<span class="flex-1 truncate">{{ display || placeholder }}</span>
<!-- span (not button): nested interactive elements inside Button are invalid HTML -->
<span
v-if="effectiveAllowClear && parsed && !disabled && !readOnly"
role="button"
tabindex="-1"
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"
@mousedown.prevent
>
<X class="size-3.5" aria-hidden="true" />
</span>
</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>
<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))
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) % 24
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-48 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" aria-hidden="true" />
<span class="flex-1 truncate">{{ display || placeholder }}</span>
<!-- span (not button): nested interactive elements inside Button are invalid HTML -->
<span
v-if="effectiveAllowClear && display && !disabled && !readOnly"
role="button"
tabindex="-1"
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"
@mousedown.prevent
>
<X class="size-3.5" aria-hidden="true" />
</span>
</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 focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none"
@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>
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