Time Picker
A <TimePickerInput />
component built with Svelte and Shadcn UI or Headless.
Demo
- Listens to
keydown
events - Supports arrow navigation
- Formats date values
- Optimizes mobile keyboard
Get Started
- Install shadcn/ui (optional) and @internationalized/date include input component Input component (twelve-hour clocks also need a Select component)
- Copy & paste time-picker-utils.ts
- Copy & paste time-picker-input.svelte
- Define your
TimePicker
component (e.g. time-picker-svelte )
Snippets
<script lang="ts" module>
import { z } from 'zod';
export const formSchema = z.object({
dob: z.string().refine((v) => v, { message: 'A date of birth is required.' })
});
export type FormSchema = typeof formSchema;
</script>
<script lang="ts">
import {
type DateValue,
parseDateTime,
} from '@internationalized/date';
import type { Infer, SuperValidated } from 'sveltekit-superforms';
import SuperDebug, { superForm } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters';
import { toast } from 'svelte-sonner';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Form from '$lib/components/ui/form';
import DateTimePicker from './date-time-picker.svelte';
let data: SuperValidated<Infer<FormSchema>> = $page.data.datePicker;
export { data as form };
const form = superForm(data, {
validators: zodClient(formSchema),
taintedMessage: null,
onUpdated: ({ form: f }) => {
if (f.valid) {
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);
} else {
toast.error('Please fix the errors in the form.');
}
}
});
const { form: formData, enhance } = form;
let value = $state<DateValue | undefined>();
$effect(() => {
value = $formData.dob ? parseDateTime($formData.dob) : undefined;
});
</script>
<form method="POST" action="/?/datePicker" class="space-y-8" use:enhance>
<Form.Field {form} name="dob" class="flex flex-col">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Date of birth</Form.Label>
<DateTimePicker
bind:date={value}
setDate={(v) => {
if (v) {
$formData.dob = v.toString();
} else {
$formData.dob = '';
}
}}
/>
<Form.Description>Your date of birth is used to calculator your age</Form.Description>
<Form.FieldErrors />
<input hidden value={$formData.dob} name={props.name} />
{/snippet}
</Form.Control>
</Form.Field>
<Button type="submit">Submit</Button>
{#if browser}
<SuperDebug data={$formData} />
{/if}
</form>
<script lang="ts">
import {
DateFormatter,
type DateValue,
getLocalTimeZone,
Time,
now
} from "@internationalized/date";
import { Calendar } from "$lib/components/ui/calendar";
import * as Popover from "$lib/components/ui/popover";
import CalendarIcon from "lucide-svelte/icons/calendar";
import { buttonVariants } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
import TimePicker from "./time-picker.svelte";
const df = new DateFormatter("en-US", {
weekday: 'long',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23'
});
let contentRef = $state<HTMLElement | null>(null);
let dateValue = $state<DateValue>();
let {
date = $bindable(
now(getLocalTimeZone())
),
setDate
}: {
date?: DateValue;
setDate?: (date: DateValue) => void;
} = $props();
let time = $state(new Time(date?.hour ?? 0, date?.minute ?? 0));
function onValueChange(_date: DateValue | undefined) {
date = date?.set({
year: _date?.year,
month: _date?.month,
day: _date?.day,
minute: time.minute,
hour: time.hour,
second: time.second,
});
setDate?.(date);
}
function setTime(time: Time) {
date = date?.set({
minute: time.minute,
hour: time.hour,
second: time.second,
});
setDate?.(date);
}
</script>
<Popover.Root>
<Popover.Trigger
class={cn(
buttonVariants({
variant: "outline",
class: "w-[280px] justify-start text-left font-normal",
}),
!date && "text-muted-foreground"
)}
>
<CalendarIcon />
{date ? df.format(date.toDate(getLocalTimeZone())) : "Pick a date"}
</Popover.Trigger>
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
<div class="flex p-2 border-b">
<TimePicker
bind:time
setTime={(time) => {
time && setTime(time);
}}
/>
</div>
<Calendar {onValueChange} type="single" bind:value={dateValue} />
</Popover.Content>
</Popover.Root>
<script lang="ts" module>
import type { Time as TimeType } from '@internationalized/date';
import type { Period } from './time-picker-utils';
import type { WithElementRef } from 'bits-ui';
import type { HTMLButtonAttributes } from 'svelte/elements';
export type PeriodSelectorProps = WithElementRef<HTMLButtonAttributes> & {
period: Period;
setPeriod?: (period: PeriodSelectorProps['period']) => void;
time: TimeType | undefined;
setTime?: (time: TimeType) => void;
onRightFocus?: () => void;
onLeftFocus?: () => void;
};
</script>
<script lang="ts">
import { display12HourValue, setDateByType } from './time-picker-utils';
import { Time } from '@internationalized/date';
import * as Select from '$lib/components/ui/select';
import { onMount } from 'svelte';
let {
period = $bindable('PM'),
time = $bindable(new Time(0, 0)),
ref,
onLeftFocus,
onRightFocus,
setPeriod,
setTime
}: PeriodSelectorProps = $props();
function handlePeriod() {
const tempTime = time.copy();
const hours = display12HourValue(time.hour);
const _time = setDateByType(
tempTime,
hours.toString(),
'12hours',
period === 'AM' ? 'PM' : 'AM'
);
time = _time;
setTime?.(_time);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowRight') onRightFocus?.();
if (e.key === 'ArrowLeft') onLeftFocus?.();
}
function handleValueChange(value: Period) {
period = value;
setPeriod?.(value);
/**
* trigger an update whenever the user switches between AM and PM;
* otherwise user must manually change the hour each time
*/
if (time) {
handlePeriod();
}
}
onMount(() => {
handlePeriod();
});
</script>
<div class="flex h-10 items-center">
<Select.Root
type="single"
bind:value={period}
onValueChange={(value) => handleValueChange(value as Period)}
>
<Select.Trigger
bind:ref
class="w-[65px] focus:bg-accent focus:text-accent-foreground"
onkeydown={handleKeyDown}>{period ?? ''}</Select.Trigger
>
<Select.Content>
<Select.Item value="AM">AM</Select.Item>
<Select.Item value="PM">PM</Select.Item>
</Select.Content>
</Select.Root>
</div>
<script lang="ts">
import { Time } from '@internationalized/date';
import { Label } from '$lib/components/ui/label';
import TimePickerInput from './time-picker-input.svelte';
import { cn } from '$lib/utils';
import TimePeriodSelect from './time-period-select.svelte';
import type { Period } from './time-picker-utils';
let {
time = $bindable(new Time(0, 0)),
period = $bindable('AM'),
view = 'labels',
setTime,
setPeriod
}: {
time: Time | undefined;
period?: Period;
view?: 'labels' | 'dotted';
setTime?: (time: Time) => void;
setPeriod?: (period: Period) => void;
} = $props();
let minuteRef = $state<HTMLInputElement | null>(null);
let hourRef = $state<HTMLInputElement | null>(null);
let secondRef = $state<HTMLInputElement | null>(null);
let periodRef = $state<HTMLInputElement | null>(null);
</script>
<div class={cn('flex items-center gap-2', view === 'dotted' && 'gap-1')}>
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="hours" class="text-xs">Hours</Label>
{/if}
<TimePickerInput
picker="12hours"
bind:time
bind:ref={hourRef}
{setTime}
{period}
onRightFocus={() => minuteRef?.focus()}
/>
</div>
{#if view === 'dotted'}
<span class="-translate-y-[2px]">:</span>
{/if}
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="minutes" class="text-xs">Minutes</Label>
{/if}
<TimePickerInput
picker="minutes"
bind:time
bind:ref={minuteRef}
{setTime}
onLeftFocus={() => hourRef?.focus()}
onRightFocus={() => secondRef?.focus()}
/>
</div>
{#if view === 'dotted'}
<span class="-translate-y-[2px]">:</span>
{/if}
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="seconds" class="text-xs">Seconds</Label>
{/if}
<TimePickerInput
picker="seconds"
bind:time
bind:ref={secondRef}
{setTime}
onLeftFocus={() => minuteRef?.focus()}
onRightFocus={() => periodRef?.focus()}
/>
</div>
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="period" class="text-xs">Period</Label>
{/if}
<TimePeriodSelect
bind:period
bind:time
{setPeriod}
{setTime}
ref={periodRef}
onLeftFocus={() => secondRef?.focus()}
/>
</div>
</div>
<script lang="ts" module>
import type { HTMLInputAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import type { Time as TimeType } from '@internationalized/date';
export type TimePickerInputProps = WithElementRef<HTMLInputAttributes> & {
type?: string;
value?: string;
name?: string;
picker: TimePickerType;
time: TimeType | undefined;
setTime?: (time: TimeType) => void;
period?: Period;
onRightFocus?: () => void;
onLeftFocus?: () => void;
};
</script>
<script lang="ts">
import { Time } from '@internationalized/date';
import { Input } from '$lib/components/ui/input';
import { cn } from '$lib/utils';
import {
type Period,
type TimePickerType,
getArrowByType,
getDateByType,
setDateByType
} from './time-picker-utils';
let {
class: className,
type = 'tel',
value,
id,
name,
time = $bindable(new Time(0, 0)),
setTime,
picker,
period,
onLeftFocus,
onRightFocus,
onkeydown,
onchange,
ref = $bindable(null),
...restProps
}: TimePickerInputProps = $props();
let flag = $state<boolean>(false);
let intKey = $state<string>('0');
let calculatedValue = $derived(getDateByType(time, picker));
$effect(() => {
if (flag) {
const timer = setTimeout(() => {
flag = false;
}, 2000);
return () => clearTimeout(timer);
}
});
function calculateNewValue(key: string) {
/*
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
* The second entered digit will break the condition and the value will be set to 10-12.
*/
if (picker === '12hours') {
if (flag && calculatedValue.slice(1, 2) === '1' && intKey === '0') return '0' + key;
}
return !flag ? '0' + key : calculatedValue.slice(1, 2) + key;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab') return;
e.preventDefault();
if (e.key === 'ArrowRight') onRightFocus?.();
if (e.key === 'ArrowLeft') onLeftFocus?.();
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
const step = e.key === 'ArrowUp' ? 1 : -1;
const newValue = getArrowByType(calculatedValue, step, picker);
if (flag) flag = false;
const tempTime = time.copy();
time = setDateByType(tempTime, newValue, picker, period);
setTime?.(time);
}
if (e.key >= '0' && e.key <= '9') {
if (picker === '12hours') intKey = e.key;
const newValue = calculateNewValue(e.key);
if (flag) onRightFocus?.();
flag = !flag;
const tempTime = time.copy();
time = setDateByType(tempTime, newValue, picker, period);
setTime?.(time);
}
}
</script>
<Input
bind:ref
id={id || picker}
name={name || picker}
class={cn(
'w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
className
)}
value={value || calculatedValue}
onchange={(e) => {
e.preventDefault();
onchange?.(e);
}}
{type}
inputmode="decimal"
onkeydown={(e) => {
onkeydown?.(e);
handleKeyDown(e);
}}
{...restProps}
/>
import { Time } from '@internationalized/date';
/**
* regular expression to check for valid hour format (01-23)
*/
export function isValidHour(value: string) {
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
}
/**
* regular expression to check for valid 12 hour format (01-12)
*/
export function isValid12Hour(value: string) {
return /^(0[1-9]|1[0-2])$/.test(value);
}
/**
* regular expression to check for valid minute format (00-59)
*/
export function isValidMinuteOrSecond(value: string) {
return /^[0-5][0-9]$/.test(value);
}
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
export function getValidNumber(
value: string,
{ max, min = 0, loop = false }: GetValidNumberConfig
) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
if (!loop) {
if (numericValue > max) numericValue = max;
if (numericValue < min) numericValue = min;
} else {
if (numericValue > max) numericValue = min;
if (numericValue < min) numericValue = max;
}
return numericValue.toString().padStart(2, "0");
}
return "00";
}
export function getValidHour(value: string) {
if (isValidHour(value)) return value;
return getValidNumber(value, { max: 23 });
}
export function getValid12Hour(value: string) {
if (isValid12Hour(value)) return value;
return getValidNumber(value, { min: 1, max: 12 });
}
export function getValidMinuteOrSecond(value: string) {
if (isValidMinuteOrSecond(value)) return value;
return getValidNumber(value, { max: 59 });
}
type GetValidArrowNumberConfig = {
min: number;
max: number;
step: number;
};
export function getValidArrowNumber(
value: string,
{ min, max, step }: GetValidArrowNumberConfig
) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
numericValue += step;
return getValidNumber(String(numericValue), { min, max, loop: true });
}
return "00";
}
export function getValidArrowHour(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 23, step });
}
export function getValidArrow12Hour(value: string, step: number) {
return getValidArrowNumber(value, { min: 1, max: 12, step });
}
export function getValidArrowMinuteOrSecond(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 59, step });
}
export function setMinutes(time: Time, value: string) {
const minutes = getValidMinuteOrSecond(value);
return time.set({ minute: parseInt(minutes, 10) });
}
export function setSeconds(time: Time, value: string) {
const seconds = getValidMinuteOrSecond(value);
return time.set({ second: parseInt(seconds, 10) });
}
export function setHours(time: Time, value: string) {
const hours = getValidHour(value);
return time.set({ hour: parseInt(hours, 10) });
}
export function set12Hours(time: Time, value: string, period: Period) {
const hours = parseInt(getValid12Hour(value), 10);
const convertedHours = convert12HourTo24Hour(hours, period);
return time.set({ hour: convertedHours });
}
export type TimePickerType = "minutes" | "seconds" | "hours" | "12hours";
export type Period = "AM" | "PM";
export function setDateByType(
time: Time,
value: string,
type: TimePickerType,
period?: Period
) {
switch (type) {
case "minutes":
return setMinutes(time, value);
case "seconds":
return setSeconds(time, value);
case "hours":
return setHours(time, value);
case "12hours": {
if (!period) return time;
return set12Hours(time, value, period);
}
default:
return time;
}
}
export function getDateByType(time: Time, type: TimePickerType) {
switch (type) {
case "minutes":
return getValidMinuteOrSecond(String(time.minute));
case "seconds":
return getValidMinuteOrSecond(String(time.second));
case "hours":
return getValidHour(String(time.hour));
case "12hours":
const hours = display12HourValue(time.hour);
return getValid12Hour(String(hours));
default:
return "00";
}
}
export function getArrowByType(
value: string,
step: number,
type: TimePickerType
) {
switch (type) {
case "minutes":
return getValidArrowMinuteOrSecond(value, step);
case "seconds":
return getValidArrowMinuteOrSecond(value, step);
case "hours":
return getValidArrowHour(value, step);
case "12hours":
return getValidArrow12Hour(value, step);
default:
return "00";
}
}
/**
* handles value change of 12-hour input
* 12:00 PM is 12:00
* 12:00 AM is 00:00
*/
export function convert12HourTo24Hour(hour: number, period: Period) {
if (period === "PM") {
if (hour <= 11) {
return hour + 12;
} else {
return hour;
}
} else if (period === "AM") {
if (hour === 12) return 0;
return hour;
}
return hour;
}
/**
* time is stored in the 24-hour form,
* but needs to be displayed to the user
* in its 12-hour representation
*/
export function display12HourValue(hours: number) {
if (hours === 0 || hours === 12) return "12";
if (hours >= 22) return `${hours - 12}`;
if (hours % 12 > 9) return `${hours}`;
return `0${hours % 12}`;
}
<script lang="ts">
import { Time } from '@internationalized/date';
import { Label } from '$lib/components/ui/label';
import TimePickerInput from './time-picker-input.svelte';
import { cn } from '$lib/utils';
let {
time = $bindable(new Time(0, 0)),
view = 'labels',
setTime
}: {
time: Time | undefined;
view?: 'labels' | 'dotted';
setTime?: (time: Time) => void;
} = $props();
let minuteRef = $state<HTMLInputElement | null>(null);
let hourRef = $state<HTMLInputElement | null>(null);
let secondRef = $state<HTMLInputElement | null>(null);
</script>
<div class={cn('flex items-center gap-2', view === 'dotted' && 'gap-1')}>
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="hours" class="text-xs">Hours</Label>
{/if}
<TimePickerInput
picker="hours"
bind:time
bind:ref={hourRef}
{setTime}
onRightFocus={() => minuteRef?.focus()}
/>
</div>
{#if view === 'dotted'}
<span class="-translate-y-[2px]">:</span>
{/if}
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="minutes" class="text-xs">Minutes</Label>
{/if}
<TimePickerInput
picker="minutes"
bind:time
bind:ref={minuteRef}
{setTime}
onLeftFocus={() => hourRef?.focus()}
onRightFocus={() => secondRef?.focus()}
/>
</div>
{#if view === 'dotted'}
<span class="-translate-y-[2px]">:</span>
{/if}
<div class="grid gap-1 text-center">
{#if view === 'labels'}
<Label for="seconds" class="text-xs">Seconds</Label>
{/if}
<TimePickerInput
picker="seconds"
bind:time
bind:ref={secondRef}
{setTime}
onLeftFocus={() => minuteRef?.focus()}
/>
</div>
</div>
Originally created by OpenStatus
adapted to Svelte by 1bye