Time Picker
time-pickeruiStandalone time input — hours, minutes, optional seconds, and 12h/24h modes. Pairs with Date Picker for full datetime entry.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/time-picker.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/time-picker.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/time-picker.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/time-picker.jsonManifest:
https://uipkge.dev/r/svelte/time-picker.jsonInstalls to:src/lib/components/ui/time-picker/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueTime 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 | TimeFormat | 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 |
onValueChangeCalled with the new 24h time string after every pick. | (value: string) => void | — | optional |
npm dependencies
Includes
Used by
Files installed (4)
src/lib/components/ui/time-picker/TimeColumns.svelte10.2 kB
<script lang="ts" module> export interface TimeParts { hour24: number minute: number second: number } export type TimeFormat = 'HH:mm' | 'HH:mm:ss' | 'hh:mm A' export interface TimeColumnsProps { /** Time value. HH:mm or HH:mm:ss depending on format. Empty = no selection. */ value?: 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 /** Called with the new 24h time string after every pick. */ onValueChange?: (value: string) => void } </script> <script lang="ts"> import { tick } from 'svelte' import { cn } from '$lib/utils' let { value = $bindable(''), use24Hour = false, use12Hours = false, minuteStep = 5, hourStep = 1, secondStep = 1, minTime, maxTime, format = 'HH:mm', disabledHours, disabledMinutes, disabledSeconds, hideDisabledOptions = false, visible = true, onValueChange, }: TimeColumnsProps = $props() 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 (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 } let parts: TimeParts | null = $state(parse(value)) $effect(() => { parts = parse(value) }) const showSeconds = $derived(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 = $derived.by(() => { if (format === 'hh:mm A') return true if (use12Hours) return true if (use24Hour) return false return false }) const hour12 = $derived.by(() => { if (!parts) return null const h = parts.hour24 % 12 return h === 0 ? 12 : h }) const period = $derived(parts && parts.hour24 >= 12 ? 'PM' : 'AM') const minBound = $derived(parse(minTime ?? '') ?? null) const maxBound = $derived(parse(maxTime ?? '') ?? null) function withinBounds(p: TimeParts) { const m = toMinutes(p) if (minBound && m < toMinutes(minBound)) return false if (maxBound && m > toMinutes(maxBound)) return false return true } const disabledHoursSet = $derived(new Set(disabledHours ? disabledHours() : [])) const disabledMinutesSet = $derived.by(() => { const h = parts?.hour24 ?? 0 return new Set(disabledMinutes ? disabledMinutes(h) : []) }) const disabledSecondsSet = $derived.by(() => { const h = parts?.hour24 ?? 0 const m = parts?.minute ?? 0 return new Set(disabledSeconds ? disabledSeconds(h, m) : []) }) function isHourDisabledItem(h: number) { if (disabledHoursSet.has(h)) return true const cur = parts ?? { hour24: 0, minute: 0, second: 0 } if (effective12Hour) { const isPM = period === '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.has(m)) return true const cur = parts ?? { hour24: 0, minute: 0, second: 0 } return !withinBounds({ hour24: cur.hour24, minute: m, second: cur.second }) } function isSecondDisabledItem(s: number) { if (disabledSecondsSet.has(s)) return true const cur = parts ?? { hour24: 0, minute: 0, second: 0 } return !withinBounds({ hour24: cur.hour24, minute: cur.minute, second: s }) } const hours12List = $derived.by(() => { const step = Math.max(1, hourStep) const list = Array.from({ length: 12 }, (_, i) => i + 1).filter((h) => (h - 1) % step === 0) if (!hideDisabledOptions) return list return list.filter((h) => !isHourDisabledItem(h)) }) const hours24List = $derived.by(() => { const step = Math.max(1, hourStep) const list = Array.from({ length: 24 }, (_, i) => i).filter((h) => h % step === 0) if (!hideDisabledOptions) return list return list.filter((h) => !isHourDisabledItem(h)) }) const minutesList = $derived.by(() => { const step = Math.max(1, Math.min(60, minuteStep)) const list = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step) if (!hideDisabledOptions) return list return list.filter((m) => !isMinuteDisabledItem(m)) }) const secondsList = $derived.by(() => { const step = Math.max(1, Math.min(60, secondStep)) const list = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step) if (!hideDisabledOptions) return list return list.filter((s) => !isSecondDisabledItem(s)) }) function commit(p: TimeParts) { if (!withinBounds(p)) return parts = p value = format24(p) onValueChange?.(value) } function pickHour12(h: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } const isPM = period === 'PM' commit({ hour24: (h % 12) + (isPM ? 12 : 0), minute: cur.minute, second: cur.second }) } function pickHour24(h: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } commit({ hour24: h, minute: cur.minute, second: cur.second }) } function pickMinute(m: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } commit({ hour24: cur.hour24, minute: m, second: cur.second }) } function pickSecond(s: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } commit({ hour24: cur.hour24, minute: cur.minute, second: s }) } function pickPeriod(p: 'AM' | 'PM') { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } const h12 = cur.hour24 % 12 commit({ hour24: h12 + (p === 'PM' ? 12 : 0), minute: cur.minute, second: cur.second }) } let hourCol: HTMLDivElement | null = $state(null) let minCol: HTMLDivElement | null = $state(null) let secCol: HTMLDivElement | null = $state(null) function scrollActiveIntoView() { for (const col of [hourCol, minCol, secCol]) { col?.querySelector<HTMLElement>('[data-active="true"]')?.scrollIntoView({ block: 'center' }) } } $effect(() => { if (!visible) return tick().then(scrollActiveIntoView) }) function isHourActive(h: number) { if (!parts) return false return effective12Hour ? hour12 === h : parts.hour24 === h } function isHourDisabled(h: number) { if (!hideDisabledOptions) return isHourDisabledItem(h) return false } function isMinuteDisabled(m: number) { if (!hideDisabledOptions) return isMinuteDisabledItem(m) return false } function isSecondDisabled(s: number) { if (!hideDisabledOptions) return isSecondDisabledItem(s) return false } const optionClass = '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' </script> <div class="flex divide-x" data-uipkge="" data-slot="time-columns"> <div bind:this={hourCol} class="h-56 overflow-y-auto"> <div class="flex w-14 flex-col p-1"> {#each effective12Hour ? hours12List : hours24List as h (h)} <button type="button" data-active={isHourActive(h)} disabled={isHourDisabled(h)} class={cn(optionClass, isHourActive(h) && 'bg-primary text-primary-foreground hover:bg-primary')} onclick={() => (effective12Hour ? pickHour12(h) : pickHour24(h))} > {String(h).padStart(2, '0')} </button> {/each} </div> </div> <div bind:this={minCol} class="h-56 overflow-y-auto"> <div class="flex w-14 flex-col p-1"> {#each minutesList as m (m)} <button type="button" data-active={parts?.minute === m} disabled={isMinuteDisabled(m)} class={cn(optionClass, parts?.minute === m && 'bg-primary text-primary-foreground hover:bg-primary')} onclick={() => pickMinute(m)} > {String(m).padStart(2, '0')} </button> {/each} </div> </div> {#if showSeconds} <div bind:this={secCol} class="h-56 overflow-y-auto"> <div class="flex w-14 flex-col p-1"> {#each secondsList as s (s)} <button type="button" data-active={parts?.second === s} disabled={isSecondDisabled(s)} class={cn(optionClass, parts?.second === s && 'bg-primary text-primary-foreground hover:bg-primary')} onclick={() => pickSecond(s)} > {String(s).padStart(2, '0')} </button> {/each} </div> </div> {/if} {#if effective12Hour} <div class="flex w-12 flex-col p-1"> {#each ['AM', 'PM'] as p (p)} <button type="button" class={cn( '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', period === p && 'bg-primary text-primary-foreground hover:bg-primary', )} onclick={() => pickPeriod(p as 'AM' | 'PM')} > {p} </button> {/each} </div> {/if} </div>src/lib/components/ui/time-picker/TimePicker.svelte8.2 kB
<script lang="ts" module> import type { TimeFormat } from './TimeColumns.svelte' export interface TimePreset { label: string value: string } export type TimePickerSize = 'small' | 'middle' | 'large' export type TimePickerStatus = 'error' | 'warning' export interface TimePickerProps { /** Controlled value as 24h HH:mm or HH:mm:ss. Empty string = no selection. */ value?: 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?: string class?: string /** Called with the new 24h time string after every change. */ onValueChange?: (value: string) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { buttonVariants } from '$lib/components/ui/button/button.variants' import { Clock, X } from '@lucide/svelte' import TimeColumns from './TimeColumns.svelte' let { value = $bindable(''), placeholder = 'Pick a time', disabled = false, readOnly = false, clearable = true, allowClear, minuteStep = 5, hourStep = 1, secondStep = 1, use24Hour = false, use12Hours = false, minTime, maxTime, format = 'HH:mm', disabledHours, disabledMinutes, disabledSeconds, hideDisabledOptions = false, presets = [], size = 'middle', status, triggerClass, class: className, onValueChange, ref = $bindable(null), }: TimePickerProps = $props() let open = $state(false) const effectiveAllowClear = $derived(allowClear !== undefined ? allowClear : 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 = $derived(parse(value)) function formatDisplay(h: number, m: number, s: number) { if (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 (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 = $derived.by(() => { if (!parsed) return '' return formatDisplay(parsed.hour24, parsed.minute, parsed.second) }) function emitTime(v: string) { value = v onValueChange?.(v) } function pickNow() { if (readOnly) return const d = new Date() const sStep = Math.max(1, 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, 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(format === 'HH:mm:ss' ? `${hourStr}:${minStr}:${secStr}` : `${hourStr}:${minStr}`) } function applyPreset(preset: TimePreset) { if (readOnly) return emitTime(preset.value) open = false } function clear(event: Event) { event.stopPropagation() if (disabled || 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 = $derived( cn( buttonVariants({ variant: 'outline' }), 'min-w-40 justify-start gap-2 text-left font-normal', !parsed && 'text-muted-foreground', sizeClasses[size], status && statusClasses[status], triggerClass, className, ), ) // Pulse the columns when reopen happens (passes via prop reactivity). let columnsVisible = $state(false) $effect(() => { columnsVisible = open }) /** Close on outside pointer-down or Escape. Hand-rolled popover behavior (no bits-ui). */ function dismissable(node: HTMLElement) { function onPointerDown(e: PointerEvent) { if (!node.contains(e.target as Node)) open = false } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') open = false } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) return { destroy() { document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) }, } } </script> <div bind:this={ref} use:dismissable data-uipkge="" data-slot="time-picker" class="relative inline-block"> <button type="button" {disabled} class={triggerClasses} aria-haspopup="dialog" aria-expanded={open} onclick={() => { if (!disabled) open = !open }} > <Clock class="size-4 shrink-0" aria-hidden="true" /> <span class="flex-1 truncate">{display || placeholder}</span> <!-- span (not button): nested interactive elements inside a button are invalid HTML --> {#if effectiveAllowClear && parsed && !disabled && !readOnly} <span 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" onclick={clear} onmousedown={(e) => e.preventDefault()} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') clear(e) }} > <X class="size-3.5" aria-hidden="true" /> </span> {/if} </button> {#if open} <div role="dialog" aria-label="Choose time" data-slot="time-picker-content" class="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-auto rounded-md border p-0 shadow-md" > {#if presets.length} <div class="flex flex-col gap-0.5 border-b p-2"> {#each presets as p (p.label)} <button 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" onclick={() => applyPreset(p)} > {p.label} </button> {/each} </div> {/if} <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" onclick={pickNow} > Now </button> </div> <TimeColumns bind:value {use24Hour} {use12Hours} {minuteStep} {hourStep} {secondStep} {minTime} {maxTime} {format} {disabledHours} {disabledMinutes} {disabledSeconds} {hideDisabledOptions} visible={columnsVisible} onValueChange={(v) => onValueChange?.(v)} /> </div> {/if} </div>src/lib/components/ui/time-picker/TimeRangePicker.svelte10.2 kB
<script lang="ts" module> import type { TimeFormat } from './TimeColumns.svelte' export interface TimeRangePreset { label: string value: [string, string] } export type TimeRangePickerSize = 'small' | 'middle' | 'large' export type TimeRangePickerStatus = 'error' | 'warning' export interface TimeRangePickerProps { /** Controlled value as [startTime, endTime] in 24h format. */ value?: [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?: string class?: string /** Called with the new [start, end] range after every change. */ onValueChange?: (value: [string, string] | null) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { buttonVariants } from '$lib/components/ui/button/button.variants' import { Clock, X } from '@lucide/svelte' import TimeColumns from './TimeColumns.svelte' let { value = $bindable(null), placeholder = 'Pick a time range', disabled = false, readOnly = false, clearable = true, allowClear, minuteStep = 5, hourStep = 1, secondStep = 1, use24Hour = false, use12Hours = false, minTime, maxTime, format = 'HH:mm', disabledHours, disabledMinutes, disabledSeconds, hideDisabledOptions = false, presets = [], size = 'middle', status, triggerClass, class: className, onValueChange, ref = $bindable(null), }: TimeRangePickerProps = $props() let open = $state(false) const effectiveAllowClear = $derived(allowClear !== undefined ? allowClear : 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 = $derived(value?.[0] ?? '') const endValue = $derived(value?.[1] ?? '') const startParsed = $derived(parse(startValue)) const endParsed = $derived(parse(endValue)) function formatDisplay(h: number, m: number, s: number) { if (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 (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 = $derived.by(() => { const hasStart = startParsed !== null const hasEnd = endParsed !== null if (!hasStart && !hasEnd) return '' const startStr = hasStart ? formatDisplay(startParsed!.hour24, startParsed!.minute, startParsed!.second) : '' const endStr = hasEnd ? formatDisplay(endParsed!.hour24, endParsed!.minute, endParsed!.second) : '' if (hasStart && hasEnd) return `${startStr} ~ ${endStr}` return startStr || endStr }) function emitRange(start: string, end: string) { value = [start, end] onValueChange?.([start, end]) } function emitStart(v: string) { emitRange(v, endValue || v) } function emitEnd(v: string) { emitRange(startValue || v, v) } function pickNow(which: 'start' | 'end') { if (readOnly) return const d = new Date() const sStep = Math.max(1, 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, 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 = format === 'HH:mm:ss' ? `${hourStr}:${minStr}:${secStr}` : `${hourStr}:${minStr}` if (which === 'start') emitStart(v) else emitEnd(v) } function applyPreset(preset: TimeRangePreset) { if (readOnly) return value = preset.value onValueChange?.(preset.value) open = false } function clear(event: Event) { event.stopPropagation() if (disabled || readOnly) return value = null onValueChange?.(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 = $derived( cn( buttonVariants({ variant: 'outline' }), 'min-w-48 justify-start gap-2 text-left font-normal', !display && 'text-muted-foreground', sizeClasses[size], status && statusClasses[status], triggerClass, className, ), ) let columnsVisible = $state(false) $effect(() => { columnsVisible = open }) /** Close on outside pointer-down or Escape. Hand-rolled popover behavior (no bits-ui). */ function dismissable(node: HTMLElement) { function onPointerDown(e: PointerEvent) { if (!node.contains(e.target as Node)) open = false } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') open = false } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) return { destroy() { document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) }, } } </script> <div bind:this={ref} use:dismissable data-uipkge="" data-slot="time-range-picker" class="relative inline-block"> <button type="button" {disabled} class={triggerClasses} aria-haspopup="dialog" aria-expanded={open} onclick={() => { if (!disabled) open = !open }} > <Clock class="size-4 shrink-0" aria-hidden="true" /> <span class="flex-1 truncate">{display || placeholder}</span> <!-- span (not button): nested interactive elements inside a button are invalid HTML --> {#if effectiveAllowClear && display && !disabled && !readOnly} <span 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" onclick={clear} onmousedown={(e) => e.preventDefault()} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') clear(e) }} > <X class="size-3.5" aria-hidden="true" /> </span> {/if} </button> {#if open} <div role="dialog" aria-label="Choose time range" data-slot="time-range-picker-content" class="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-auto rounded-md border p-0 shadow-md" > {#if presets.length} <div class="flex flex-col gap-0.5 border-b p-2"> {#each presets as p (p.label)} <button 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" onclick={() => applyPreset(p)} > {p.label} </button> {/each} </div> {/if} <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" onclick={() => 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" onclick={() => 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 value={startValue} {use24Hour} {use12Hours} {minuteStep} {hourStep} {secondStep} {minTime} {maxTime} {format} {disabledHours} {disabledMinutes} {disabledSeconds} {hideDisabledOptions} visible={columnsVisible} onValueChange={emitStart} /> </div> <div class="bg-border w-px"></div> <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 value={endValue} {use24Hour} {use12Hours} {minuteStep} {hourStep} {secondStep} {minTime} {maxTime} {format} {disabledHours} {disabledMinutes} {disabledSeconds} {hideDisabledOptions} visible={columnsVisible} onValueChange={emitEnd} /> </div> </div> </div> {/if} </div>src/lib/components/ui/time-picker/index.ts0.4 kB
export { default as TimePicker, type TimePickerProps, type TimePreset, type TimePickerSize, type TimePickerStatus } from './TimePicker.svelte' export { default as TimeRangePicker, type TimeRangePickerProps, type TimeRangePreset, type TimeRangePickerSize, type TimeRangePickerStatus } from './TimeRangePicker.svelte' export { default as TimeColumns, type TimeColumnsProps, type TimeParts, type TimeFormat } from './TimeColumns.svelte'
Raw manifest:https://uipkge.dev/r/svelte/time-picker.json