Schedule Picker
Choose a date and time in one field. Type naturally or use the calendar and time controls, with the timezone always visible. The value is an exact ISO instant.
Schedule Picker accepts future times by default. Add allowPast when editing historical records or choosing a time on either side of today.
Installation
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add schedule-picker- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
When to use
Use Schedule Picker whenever both the day and time matter:
- Schedule publishing, sending, appointments, or jobs with the future-only default.
- Edit a record's timestamp, log an event, or search historical records with
allowPast. - Restrict a datetime to a permitted window with
minandmax.
When not to use
Use Date Picker when only the day matters, Date Range Picker for a date-only period, and Calendar when the calendar itself is the workspace. An invoice due date or birthday should stay a date-only value; do not add a midnight time or timezone to make it fit Schedule Picker.
Usage
Vue
<script setup>
import { ref } from 'vue'
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.vue'
const publishAt = ref('')
</script>
<template>
<label for="publish-at">Publish at</label>
<SchedulePicker
id="publish-at"
v-model="publishAt"
name="publishAt"
time-zone="Africa/Lagos"
required
/>
</template>
React
import { useState } from 'react'
import SchedulePicker from '@/components/ui/schedule-picker/SchedulePicker.jsx'
export default function PublishSchedule() {
const [publishAt, setPublishAt] = useState('')
return (
<>
<label htmlFor="publish-at">Publish at</label>
<SchedulePicker
id="publish-at"
value={publishAt}
onValueChange={setPublishAt}
name="publishAt"
timeZone="Africa/Lagos"
required
/>
</>
)
}
Svelte
<script>
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.svelte'
let publishAt = $state('')
</script>
<label for="publish-at">Publish at</label>
<SchedulePicker
id="publish-at"
bind:value={publishAt}
name="publishAt"
timeZone="Africa/Lagos"
required
/>
Historical dates and times
Add allowPast to accept both past and future instants. The same field, calendar, time controls, and natural input work for editing records—there is no separate datetime component to learn.
Vue
<script setup>
import { ref } from 'vue'
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.vue'
const recordedAt = ref('2020-02-29T13:35:00.000Z')
</script>
<template>
<label for="recorded-at">Recorded at</label>
<SchedulePicker
id="recorded-at"
v-model="recordedAt"
name="recordedAt"
allow-past
time-zone="Africa/Lagos"
locale="en-US"
placeholder="February 29, 2020 at 2:35pm"
class="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"
/>
</template>
React
import { useState } from 'react'
import SchedulePicker from '@/components/ui/schedule-picker/SchedulePicker.jsx'
export default function RecordedAt() {
const [recordedAt, setRecordedAt] = useState('2020-02-29T13:35:00.000Z')
return (
<>
<label htmlFor="recorded-at">Recorded at</label>
<SchedulePicker
id="recorded-at"
value={recordedAt}
onValueChange={setRecordedAt}
name="recordedAt"
allowPast
timeZone="Africa/Lagos"
locale="en-US"
placeholder="February 29, 2020 at 2:35pm"
className="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"
/>
</>
)
}
Svelte
<script>
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.svelte'
let recordedAt = $state('2020-02-29T13:35:00.000Z')
</script>
<label for="recorded-at">Recorded at</label>
<SchedulePicker
id="recorded-at"
bind:value={recordedAt}
name="recordedAt"
allowPast
timeZone="Africa/Lagos"
locale="en-US"
placeholder="February 29, 2020 at 2:35pm"
class="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"
/>
Allowed date and time window
min and max are inclusive ISO instants, not date-only strings. They constrain both the calendar and time selection, including typed input. A value exactly at either boundary is allowed, provided it also satisfies the future-only default.
An earlier min does not enable historical dates on its own. Use allowPast for a window that includes the past.
<script setup>
import { ref } from 'vue'
import SchedulePicker from '~/components/ui/schedule-picker/SchedulePicker.vue'
const reviewAt = ref('2020-02-29T13:35:00.000Z')
</script>
<template>
<label for="review-at">Review time</label>
<SchedulePicker
id="review-at"
v-model="reviewAt"
name="reviewAt"
allow-past
min="2020-02-29T08:00:00.000Z"
max="2020-02-29T16:00:00.000Z"
time-zone="Africa/Lagos"
locale="en-US"
/>
</template>
In Vue use allow-past; in React and Svelte use allowPast. The min and max names are identical in all three frameworks. Continue validating the permitted window on the server when saving the value.
Choosing the time
Choose the hour and minute in the compact time control beneath the calendar. A 12-hour locale such as en-US includes AM/PM; a 24-hour locale such as en-GB uses hours 00–23. The timezone stays beside the time, and the locale controls presentation, not the stored instant.
Every minute is selectable. minuteStep defaults to 15 and only rounds the starting time for an empty picker; existing selections and typed values are not rounded.
Natural input and commit
The following all create a proposal:
tomorrow at 9amFriday at 14:30in 5 minutesin one hour
The interpreted date, time, and IANA timezone remain visible. Press Enter, leave the complete picker, or choose Done to commit. Moving focus between the text field, calendar, time controls, and footer action does not commit prematurely. Until a valid choice is committed, the field retains the last valid ISO instant. An incomplete phrase or invalid edit cannot silently replace it. Choosing Done without changing an existing value simply closes the picker.
With allowPast, phrases such as yesterday at 2:35pm, 2 hours ago, and February 29, 2020 at 2:35pm are also accepted. The allowed window still applies.
Relative durations retain exact seconds. If the reference instant is 13:07:30 in Lagos, in 5 minutes proposes 13:12:30 and stores the matching UTC instant. Ordinary choices such as tomorrow at 9am remain minute-clean.
Timezone convention
Pass the account or application IANA timezone when it is known. When it is not, the browser timezone is the useful zero-configuration default. Display remains localized through Intl; the committed value remains an ISO instant suitable for storage and server scheduling.
Pass an ISO timestamp with Z or an explicit offset as the value. Do not pass a timezone-less form string such as 2020-02-29T14:35: it does not identify an exact instant. Keep date-only values as YYYY-MM-DD with Date Picker. An existing form that stores local date and time strings needs an explicit conversion at its boundary; do not append Z unless those values really are UTC.
The component handles timezone offset changes for the selected date. Local times skipped by daylight saving are rejected. When a clock time occurs twice, the earlier occurrence is used unless the input includes an explicit UTC offset. Invalid timezone input falls back to the browser timezone.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current ISO instant | v-model | value, onValueChange | bind:value |
| Initial instant | default-value | defaultValue | defaultValue |
| Native form name | name | name | name |
| Interpretation timezone | time-zone | timeZone | timeZone |
| Locale | locale, dir | locale, dir | locale, dir |
| Accept past instants | allow-past | allowPast | allowPast |
| Earliest instant | min | min | min |
| Latest instant | max | max | max |
| Initial time rounding | minute-step | minuteStep | minuteStep |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Native states | required, disabled, readonly | required, disabled, readOnly | required, disabled, readonly |
allowPast defaults to false. min and max are optional, inclusive limits; neither disables the future-only default. minuteStep sets the suggested starting time, not the precision of the value.
Styling
Use ordinary classes on the component. Target a part through its data-slot when you want to change only that part. The historical example above gives the input a dashed border without changing the text, calendar, or time controls:
class="**:data-[slot=input]:border-dashed **:data-[slot=input]:shadow-none"Use className in React. Classes and ordinary attributes remain application-owned.
Durable behavior
- Editing text does not replace the committed value until Enter or leaving the picker.
- Enter commits without moving focus from the field.
- Internal focus movement does not commit; leaving the complete picker does.
- Calendar, hour, minute, and AM/PM choices are keyboard navigable.
- Past proposals are rejected unless
allowPastis enabled. - Bounds are checked at commit, so an expired scheduling choice cannot slip through.
- Escape dismisses only ephemeral open state and returns focus predictably.
- Klean never writes the draft, open state, or selected instant to storage or the URL.
The application may persist the committed value or a form draft using its own Durable UI policy. Klean does not guess that persistence scope.
Related components
Schedule Picker combines date, time, and IANA timezone as an exact ISO instant. Choose the date-only components below when wall-clock time must not exist.
- Date Picker — one date-only
YYYY-MM-DDvalue without time or timezone. - Calendar — an always-visible date-only
YYYY-MM-DDsurface. - Date Range Picker — ordered date-only
YYYY-MM-DDperiods. - Popover — the non-modal floating behavior.
- Toast — announce the server result after a schedule is saved.
Complete framework source
Vue
<script setup>
import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.vue'
import Input from '../input/Input.vue'
import Popover from '../popover/Popover.vue'
import {
formatSchedule,
initialScheduleWallClock,
instantToWallClock,
interpretSchedule,
resolveTimeZone,
scheduleCalendarBounds,
scheduleConstraint,
timeFields,
updateTimeField,
wallClockToIso
} from './schedule.js'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** An exact ISO instant, such as 2026-08-12T08:30:00.000Z. */
modelValue: { type: String, default: undefined },
defaultValue: { type: String, default: undefined },
id: { type: String, default: undefined },
name: { type: String, default: undefined },
placeholder: { type: String, default: 'Tomorrow at 9am' },
/** IANA timezone used to interpret wall-clock input. */
timeZone: { type: String, default: undefined },
locale: { type: String, default: undefined },
dir: { type: String, default: undefined },
/** Earliest allowed ISO instant. Scheduling remains future-only by default. */
min: { type: String, default: undefined },
/** Latest allowed ISO instant, inclusive. */
max: { type: String, default: undefined },
/** Allow historical records as well as future dates. */
allowPast: { type: Boolean, default: false },
/** Round the initial suggested time to this interval. Edits may use any minute. */
minuteStep: { type: Number, default: 15 },
open: { type: Boolean, default: undefined },
defaultOpen: { type: Boolean, default: false },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'change', 'update:open'])
const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const inputId = computed(
() => props.id ?? `klean-schedule-picker-${generatedId}`
)
const popoverId = computed(() => `${inputId.value}-panel`)
const statusId = computed(() => `${inputId.value}-status`)
const timeHeadingId = computed(() => `${inputId.value}-time-heading`)
const zone = computed(() => resolveTimeZone(props.timeZone))
const validDefault = !Number.isNaN(new Date(props.defaultValue).getTime())
? props.defaultValue
: ''
const internalValue = ref(validDefault)
const value = computed(() =>
props.modelValue === undefined ? internalValue.value : props.modelValue
)
const initialWallClock = initialScheduleWallClock(value.value, {
allowPast: props.allowPast,
min: props.min,
max: props.max,
timeZone: zone.value,
minuteStep: props.minuteStep
})
const selectedDate = ref(initialWallClock.date)
const selectedTime = ref(initialWallClock.time)
const draft = ref(
value.value ? formatSchedule(value.value, props.locale, zone.value) : ''
)
const interpretation = ref(
value.value
? {
state: 'committed',
iso: value.value,
date: initialWallClock.date,
time: initialWallClock.time,
label: formatSchedule(value.value, props.locale, zone.value)
}
: { state: 'empty' }
)
const input = ref()
const popover = ref()
const panel = ref()
const root = ref()
const touched = ref(false)
const validationClock = ref(Date.now())
const constraints = computed(() => ({
allowPast: props.allowPast,
min: props.min,
max: props.max,
reference: new Date(validationClock.value)
}))
const calendarBounds = computed(() =>
scheduleCalendarBounds({ ...constraints.value, timeZone: zone.value })
)
const fields = computed(() => timeFields(selectedTime.value, props.locale))
const minutes = Array.from({ length: 60 }, (_, minute) =>
String(minute).padStart(2, '0')
)
const constraintError = computed(() =>
interpretation.value.iso
? scheduleConstraint(interpretation.value.iso, constraints.value)
: ''
)
const committable = computed(
() => interpretation.value.state === 'proposal' && !constraintError.value
)
const invalid = computed(
() =>
interpretation.value.state === 'invalid' ||
Boolean(constraintError.value) ||
(touched.value && interpretation.value.state === 'incomplete')
)
const statusText = computed(() => {
if (interpretation.value.state === 'empty') {
return 'Type a date and time, or choose them from the calendar.'
}
if (interpretation.value.state === 'invalid') {
return 'Enter a date and time, such as tomorrow at 9am.'
}
if (interpretation.value.state === 'incomplete') {
return interpretation.value.message
}
if (constraintError.value === 'past') return 'Choose a time in the future.'
if (constraintError.value === 'min')
return `Choose ${formatSchedule(props.min, props.locale, zone.value)} or later.`
if (constraintError.value === 'max')
return `Choose ${formatSchedule(props.max, props.locale, zone.value)} or earlier.`
if (interpretation.value.state === 'proposal') {
return `${props.allowPast ? 'Use' : 'Will schedule for'} ${interpretation.value.label} in ${zone.value}. Press Enter or leave the picker to use it.`
}
return `${props.allowPast ? 'Selected' : 'Scheduled for'} ${interpretation.value.label} in ${zone.value}.`
})
const inputAttrs = computed(() => {
const {
class: _class,
'data-slot': _dataSlot,
'aria-describedby': _describedBy,
...rest
} = attrs
return rest
})
const describedBy = computed(() =>
[attrs['aria-describedby'], statusId.value].filter(Boolean).join(' ')
)
const rootClasses = computed(() =>
twMerge(
'grid w-full gap-2 **:data-[slot=schedule-picker-field]:relative **:data-[slot=schedule-picker-field]:flex **:data-[slot=schedule-picker-field]:items-stretch **:data-[slot=input]:pe-12',
attrs.class
)
)
function setInternalValue(nextValue) {
if (props.modelValue === undefined) internalValue.value = nextValue
emit('update:modelValue', nextValue)
emit('change', nextValue)
}
function clear() {
setInternalValue('')
interpretation.value = { state: 'empty' }
}
function readDraft(nextDraft) {
validationClock.value = Date.now()
draft.value = nextDraft
if (!nextDraft.trim()) {
clear()
return
}
const next = interpretSchedule(nextDraft, {
reference: new Date(),
locale: props.locale,
timeZone: zone.value,
allowPast: props.allowPast
})
interpretation.value = next
if (next.date) selectedDate.value = next.date
if (next.time) selectedTime.value = next.time
}
function handleInput(event) {
touched.value = false
readDraft(event.target.value)
}
function stage(date = selectedDate.value, time = selectedTime.value) {
if (props.disabled || props.readonly) return
validationClock.value = Date.now()
// Opening or reselecting an unchanged instant must not discard its seconds.
const previous = interpretation.value
const iso =
previous.iso && previous.date === date && previous.time === time
? previous.iso
: wallClockToIso({ date, time, timeZone: zone.value })
selectedDate.value = date
selectedTime.value = time
if (!iso) {
interpretation.value = { state: 'invalid' }
return
}
const label = formatSchedule(iso, props.locale, zone.value)
interpretation.value = {
state: 'proposal',
iso,
date,
time,
label,
timeZone: zone.value
}
draft.value = label
}
function commitProposal({ restoreFocus = true } = {}) {
validationClock.value = Date.now()
if (!committable.value || props.disabled || props.readonly) return
const next = interpretation.value
setInternalValue(next.iso)
draft.value = next.label
interpretation.value = { ...next, state: 'committed' }
popover.value?.close({ restoreFocus })
}
function handleFocusOut(event) {
if (event.relatedTarget && root.value?.contains(event.relatedTarget)) return
touched.value = true
commitProposal({ restoreFocus: false })
}
function finish() {
if (props.disabled || props.readonly) return
validationClock.value = Date.now()
if (interpretation.value.state === 'committed' && !constraintError.value) {
popover.value?.close()
} else {
commitProposal()
}
}
function handleInputKeydown(event) {
if (event.key === 'ArrowDown' && !props.disabled && !props.readonly) {
event.preventDefault()
popover.value?.open()
} else if (
event.key === 'Enter' &&
(interpretation.value.state === 'proposal' ||
interpretation.value.state === 'incomplete' ||
invalid.value)
) {
event.preventDefault()
touched.value = true
commitProposal({ restoreFocus: false })
}
}
async function handleOpenUpdate(nextOpen) {
emit('update:open', nextOpen)
if (!nextOpen) return
validationClock.value = Date.now()
if (interpretation.value.state === 'empty') {
const initial = initialScheduleWallClock('', {
...constraints.value,
timeZone: zone.value,
minuteStep: props.minuteStep
})
selectedDate.value = initial.date
selectedTime.value = initial.time
}
await nextTick()
requestAnimationFrame(() => {
if (!panel.value?.getClientRects().length) return
panel.value.parentElement.scrollTop = 0
})
}
function chooseDate(nextDate) {
stage(nextDate, selectedTime.value)
}
function chooseTime(nextTime) {
stage(selectedDate.value, nextTime)
}
function chooseTimeField(part, nextValue) {
chooseTime(updateTimeField(selectedTime.value, part, nextValue, props.locale))
}
watch([value, zone, () => props.locale], ([nextValue]) => {
const wallClock = instantToWallClock(nextValue, zone.value)
if (!wallClock) {
if (!nextValue) {
draft.value = ''
interpretation.value = { state: 'empty' }
}
return
}
selectedDate.value = wallClock.date
selectedTime.value = wallClock.time
const label = formatSchedule(nextValue, props.locale, zone.value)
draft.value = label
interpretation.value = {
state: 'committed',
iso: nextValue,
...wallClock,
label
}
})
watch(
() => [
invalid.value,
committable.value,
props.required,
draft.value,
value.value
],
async () => {
await nextTick()
const element = input.value?.element
if (!element) return
if (props.required && !value.value) {
element.setCustomValidity('Choose a date and time.')
} else if (invalid.value || interpretation.value.state === 'incomplete') {
element.setCustomValidity(statusText.value)
} else {
element.setCustomValidity('')
}
},
{ immediate: true }
)
defineExpose({
input,
focus: (options) => input.value?.focus(options),
open: () => popover.value?.open(),
close: () => popover.value?.close()
})
</script>
<template>
<div
ref="root"
data-slot="schedule-picker"
:data-state="interpretation.state"
:class="rootClasses"
@focusout="handleFocusOut"
>
<div data-slot="schedule-picker-field">
<Input
ref="input"
v-bind="inputAttrs"
:id="inputId"
type="text"
autocomplete="off"
:value="draft"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:aria-invalid="invalid || undefined"
:aria-describedby="describedBy"
@input="handleInput"
@click="!disabled && !readonly && popover?.open()"
@keydown="handleInputKeydown"
/>
<button
type="button"
:popovertarget="popoverId"
data-slot="schedule-picker-button"
class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
:disabled="disabled || readonly"
aria-label="Choose a date and time"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-5"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</button>
</div>
<input
v-if="name"
type="hidden"
:name="name"
:value="value"
:disabled="disabled"
/>
<p
:id="statusId"
data-slot="schedule-picker-status"
class="text-sm text-gray-600 aria-invalid:text-red-700 dark:text-gray-400 dark:aria-invalid:text-red-400"
:aria-invalid="invalid"
aria-live="polite"
>
{{ statusText }}
</p>
<Popover
ref="popover"
:id="popoverId"
:anchor="inputId"
:open="open"
:default-open="defaultOpen"
placement="bottom-start"
data-slot="schedule-picker-popover"
class="max-h-[min(var(--klean-popover-available-height,100dvh),calc(100dvh-1rem))] w-[min(24rem,calc(100vw-1rem))] overflow-y-auto overscroll-contain rounded-xl p-0"
@update:open="handleOpenUpdate"
>
<div ref="panel" data-slot="schedule-picker-panel">
<div class="grid">
<Calendar
:model-value="selectedDate"
:min="calendarBounds.min"
:max="calendarBounds.max"
:locale="locale"
:dir="dir"
:disabled="disabled"
:readonly="readonly"
class="max-w-none p-4"
@update:model-value="chooseDate"
/>
<section
data-slot="schedule-picker-times"
class="sticky bottom-0 grid min-w-0 gap-2 border-t border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950"
:aria-labelledby="timeHeadingId"
>
<div class="flex min-w-0 items-baseline justify-between gap-3">
<h2 :id="timeHeadingId" class="text-sm font-medium">Time</h2>
<p
:id="`${inputId}-time-zone`"
data-slot="schedule-picker-time-zone"
class="min-w-0 text-end text-xs wrap-anywhere text-gray-500 dark:text-gray-400"
>
{{ zone }}
</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="schedule-picker-time-fields"
role="group"
:aria-labelledby="timeHeadingId"
:aria-describedby="`${inputId}-time-zone`"
dir="ltr"
class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-sm font-medium tabular-nums shadow-xs dark:border-gray-700 dark:bg-gray-900"
>
<select
data-slot="schedule-picker-hour"
aria-label="Hour"
class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
:value="fields.hour"
:disabled="disabled || readonly"
@change="chooseTimeField('hour', $event.target.value)"
>
<option
v-for="hour in fields.hours"
:key="hour.value"
:value="hour.value"
>
{{ hour.label }}
</option>
</select>
<span aria-hidden="true" class="text-gray-400">:</span>
<select
data-slot="schedule-picker-minute"
aria-label="Minute"
class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
:value="fields.minute"
:disabled="disabled || readonly"
@change="chooseTimeField('minute', $event.target.value)"
>
<option
v-for="minute in minutes"
:key="minute"
:value="minute"
>
{{ minute }}
</option>
</select>
<div
v-if="fields.hour12"
class="relative ms-1 border-s border-gray-200 ps-1 dark:border-gray-700"
>
<select
data-slot="schedule-picker-period"
aria-label="Period"
class="h-11 min-w-16 cursor-pointer appearance-none rounded-md bg-transparent ps-2 pe-6 text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
:value="fields.period"
:disabled="disabled || readonly"
@change="chooseTimeField('period', $event.target.value)"
>
<option
v-for="period in fields.periods"
:key="period.value"
:value="period.value"
>
{{ period.label }}
</option>
</select>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="pointer-events-none absolute inset-e-2 top-1/2 size-3 -translate-y-1/2 text-gray-500 dark:text-gray-400"
>
<path d="m7 10 5 5 5-5" />
</svg>
</div>
</div>
<div data-slot="schedule-picker-footer">
<button
type="button"
data-slot="schedule-picker-confirm"
class="min-h-11 cursor-pointer rounded-lg bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200 dark:focus-visible:outline-white"
:disabled="
(!committable && interpretation.state !== 'committed') ||
invalid ||
disabled ||
readonly
"
@click="finish()"
>
Done
</button>
</div>
</div>
<p v-if="invalid" class="text-sm text-red-700 dark:text-red-400">
{{ statusText }}
</p>
</section>
</div>
</div>
</Popover>
</div>
</template>
import {
CalendarDateTime,
fromAbsolute,
toZoned
} from '@internationalized/date'
import { en as chrono } from 'chrono-node'
import { dateLabel, parseIsoDate, resolveLocale } from '../calendar/date.js'
export function resolveTimeZone(timeZone) {
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const candidate = timeZone || fallback
try {
new Intl.DateTimeFormat('en', { timeZone: candidate }).format()
return candidate
} catch {
return fallback
}
}
export function parseTime(value) {
const match = /^(\d{2}):(\d{2})$/.exec(value ?? '')
if (!match) return undefined
const hour = Number(match[1])
const minute = Number(match[2])
if (hour > 23 || minute > 59) return undefined
return { hour, minute }
}
export function formatTime({ hour, minute }) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
export function wallClockToIso({
date,
time,
timeZone,
timezoneOffset,
second = 0,
millisecond = 0
}) {
const parsedDate = parseIsoDate(date)
const parsedTime = parseTime(time)
if (!parsedDate || !parsedTime) return undefined
if (Number.isFinite(timezoneOffset)) {
return new Date(
Date.UTC(
parsedDate.year,
parsedDate.month - 1,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
) +
-timezoneOffset * 60_000
).toISOString()
}
try {
const zoned = toZoned(
new CalendarDateTime(
parsedDate.year,
parsedDate.month,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
),
resolveTimeZone(timeZone),
'compatible'
)
// A gap must not silently move an entered time forward. During an overlap,
// compatible disambiguation keeps the earlier occurrence.
if (
zoned.year !== parsedDate.year ||
zoned.month !== parsedDate.month ||
zoned.day !== parsedDate.day ||
zoned.hour !== parsedTime.hour ||
zoned.minute !== parsedTime.minute ||
zoned.second !== second ||
zoned.millisecond !== millisecond
) {
return undefined
}
return zoned.toDate().toISOString()
} catch {
return undefined
}
}
export function instantToWallClock(value, timeZone) {
const instant = new Date(value)
if (Number.isNaN(instant.getTime())) return undefined
const zoned = fromAbsolute(instant.getTime(), resolveTimeZone(timeZone))
return {
date: `${String(zoned.year).padStart(4, '0')}-${String(zoned.month).padStart(2, '0')}-${String(zoned.day).padStart(2, '0')}`,
time: formatTime({ hour: zoned.hour, minute: zoned.minute })
}
}
export function formatSchedule(value, locale, timeZone) {
const instant = new Date(value)
if (Number.isNaN(instant.getTime())) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: resolveTimeZone(timeZone),
dateStyle: 'medium',
timeStyle:
instant.getUTCSeconds() || instant.getUTCMilliseconds()
? 'medium'
: 'short'
}).format(instant)
}
export function formatTimeLabel(value, locale) {
const time = parseTime(value)
if (!time) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(Date.UTC(2020, 0, 1, time.hour, time.minute)))
}
function timestamp(value) {
if (value === undefined || value === null || value === '') return NaN
return new Date(value).getTime()
}
function referenceTimestamp(reference) {
const value = timestamp(reference)
return Number.isFinite(value) ? value : Date.now()
}
export function scheduleConstraint(
value,
{ allowPast = false, min, max, reference = new Date() } = {}
) {
const instant = timestamp(value)
if (!Number.isFinite(instant)) return 'invalid'
if (!allowPast && instant <= referenceTimestamp(reference)) return 'past'
if (instant < timestamp(min)) return 'min'
if (instant > timestamp(max)) return 'max'
return ''
}
export function scheduleCalendarBounds({
allowPast = false,
min,
max,
timeZone,
reference = new Date()
} = {}) {
const configuredMin = timestamp(min)
const lower = Math.max(
allowPast ? -Infinity : referenceTimestamp(reference) + 1,
Number.isFinite(configuredMin) ? configuredMin : -Infinity
)
const upper = timestamp(max)
return {
min: Number.isFinite(lower)
? instantToWallClock(lower, timeZone)?.date
: undefined,
max: Number.isFinite(upper)
? instantToWallClock(upper, timeZone)?.date
: undefined
}
}
export function timeFields(value, locale) {
const time = parseTime(value) ?? { hour: 0, minute: 0 }
const resolvedLocale = resolveLocale(locale)
const hour12 = new Intl.DateTimeFormat(resolvedLocale, {
hour: 'numeric'
}).resolvedOptions().hour12
const hoursFormatter = new Intl.DateTimeFormat(resolvedLocale, {
timeZone: 'UTC',
hour: '2-digit',
hourCycle: hour12 ? 'h12' : 'h23'
})
const periodsFormatter = new Intl.DateTimeFormat(resolvedLocale, {
timeZone: 'UTC',
hour: 'numeric',
hourCycle: 'h12'
})
const part = (formatter, hour, type) =>
formatter
.formatToParts(new Date(Date.UTC(2020, 0, 1, hour)))
.find((item) => item.type === type)?.value
return {
hour: String(hour12 ? time.hour % 12 || 12 : time.hour),
minute: String(time.minute).padStart(2, '0'),
period: time.hour < 12 ? 'am' : 'pm',
hour12,
periods: [
{ value: 'am', label: part(periodsFormatter, 6, 'dayPeriod') ?? 'AM' },
{ value: 'pm', label: part(periodsFormatter, 18, 'dayPeriod') ?? 'PM' }
],
hours: Array.from({ length: hour12 ? 12 : 24 }, (_, index) => {
const hour = hour12 ? index + 1 : index
return {
value: String(hour),
label: part(hoursFormatter, hour, 'hour') ?? String(hour)
}
})
}
}
export function updateTimeField(time, part, value, locale) {
const current = parseTime(time)
if (!current) return time
const fields = timeFields(time, locale)
if (part === 'period') {
if (!fields.hour12 || !['am', 'pm'].includes(value)) return time
current.hour = (current.hour % 12) + (value === 'pm' ? 12 : 0)
} else {
if (!/^\d{1,2}$/.test(String(value))) return time
const number = Number(value)
if (part === 'minute') {
if (number > 59) return time
current.minute = number
} else if (part === 'hour') {
if (fields.hour12) {
if (number < 1 || number > 12) return time
current.hour = (number % 12) + (fields.period === 'pm' ? 12 : 0)
} else {
if (number > 23) return time
current.hour = number
}
} else {
return time
}
}
return formatTime(current)
}
function normalizeMinuteStep(step) {
const value = Number(step)
return Number.isFinite(value)
? Math.min(60, Math.max(1, Math.round(value)))
: 15
}
export function timeOptions(step = 15) {
const safeStep = normalizeMinuteStep(step)
const values = []
for (let minute = 0; minute < 24 * 60; minute += safeStep) {
values.push(
formatTime({ hour: Math.floor(minute / 60), minute: minute % 60 })
)
}
return values
}
function roundedFutureTimestamp(reference, step) {
const amount = normalizeMinuteStep(step)
const rounded = new Date(referenceTimestamp(reference))
rounded.setSeconds(0, 0)
const remainder = rounded.getMinutes() % amount
rounded.setMinutes(
rounded.getMinutes() + (remainder ? amount - remainder : amount)
)
return rounded.getTime()
}
export function roundedFutureWallClock(reference, timeZone, step = 15) {
return instantToWallClock(roundedFutureTimestamp(reference, step), timeZone)
}
export function initialScheduleWallClock(
value,
{
allowPast = false,
min,
max,
timeZone,
minuteStep = 15,
reference = new Date()
} = {}
) {
const current = timestamp(value)
if (Number.isFinite(current)) return instantToWallClock(current, timeZone)
const now = referenceTimestamp(reference)
const initial = roundedFutureTimestamp(now, minuteStep)
const configuredMin = timestamp(min)
const configuredMax = timestamp(max)
const lower = Math.max(
allowPast ? -Infinity : now + 1,
Number.isFinite(configuredMin) ? configuredMin : -Infinity
)
const upper = Number.isFinite(configuredMax) ? configuredMax : Infinity
// Bounds still disable every choice when the allowed interval is empty.
// Initializing a view must not imply that a forbidden instant is valid.
const candidate =
lower <= upper ? Math.min(upper, Math.max(lower, initial)) : initial
return instantToWallClock(candidate, timeZone)
}
export function interpretSchedule(
text,
{ reference = new Date(), locale, timeZone, allowPast = false } = {}
) {
const source = text?.trim()
if (!source) return { state: 'empty' }
const zone = resolveTimeZone(timeZone)
const referenceDate =
reference instanceof Date ? reference : new Date(reference)
const referenceInstant = Number.isNaN(referenceDate.getTime())
? new Date()
: referenceDate
const zonedReference = fromAbsolute(referenceInstant.getTime(), zone)
const result = chrono.parse(
source,
{
instant: referenceInstant,
timezone: zonedReference.offset / 60_000
},
{ forwardDate: !allowPast }
)[0]
if (!result) return { state: 'invalid' }
const date = `${String(result.start.get('year')).padStart(4, '0')}-${String(result.start.get('month')).padStart(2, '0')}-${String(result.start.get('day')).padStart(2, '0')}`
const hasTime = result.start.isCertain('hour')
if (!hasTime) {
return {
state: 'incomplete',
date,
message: `${dateLabel(date, locale)} needs a time.`
}
}
const time = formatTime({
hour: result.start.get('hour'),
minute: result.start.get('minute') ?? 0
})
const timezoneOffset = result.start.isCertain('timezoneOffset')
? result.start.get('timezoneOffset')
: undefined
const iso = wallClockToIso({
date,
time,
timeZone: zone,
timezoneOffset,
second: result.start.get('second') ?? 0,
millisecond: result.start.get('millisecond') ?? 0
})
if (!iso) return { state: 'invalid' }
return {
state: 'proposal',
...instantToWallClock(iso, zone),
iso,
label: formatSchedule(iso, locale, zone),
timeZone: zone
}
}
React
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.jsx'
import Input from '../input/Input.jsx'
import Popover from '../popover/Popover.jsx'
import {
formatSchedule,
instantToWallClock,
initialScheduleWallClock,
interpretSchedule,
resolveTimeZone,
scheduleCalendarBounds,
scheduleConstraint,
timeFields,
updateTimeField,
wallClockToIso
} from './schedule.js'
const SchedulePicker = forwardRef(function SchedulePicker(
{
value,
defaultValue,
onValueChange,
onChange,
id,
name,
placeholder = 'Tomorrow at 9am',
timeZone,
locale,
dir,
allowPast = false,
min,
max,
minuteStep = 15,
open,
defaultOpen = false,
onOpenChange,
required = false,
disabled = false,
readOnly = false,
className,
'aria-describedby': externalDescribedBy,
...inputProps
},
forwardedRef
) {
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const inputId = id ?? `klean-schedule-picker-${generatedId}`
const popoverId = `${inputId}-panel`
const statusId = `${inputId}-status`
const timeHeadingId = `${inputId}-time-heading`
const zone = resolveTimeZone(timeZone)
const validDefault = Number.isNaN(new Date(defaultValue).getTime())
? ''
: defaultValue
const [internalValue, setInternalValue] = useState(validDefault)
const committedValue = value === undefined ? internalValue : value
const initialWall = initialScheduleWallClock(committedValue, {
allowPast,
min,
max,
timeZone: zone,
minuteStep
})
const [selectedDate, setSelectedDate] = useState(initialWall.date)
const [selectedTime, setSelectedTime] = useState(initialWall.time)
const [draft, setDraft] = useState(
committedValue ? formatSchedule(committedValue, locale, zone) : ''
)
const [interpretation, setInterpretation] = useState(
committedValue
? {
state: 'committed',
iso: committedValue,
...initialWall,
label: formatSchedule(committedValue, locale, zone)
}
: { state: 'empty' }
)
const [touched, setTouched] = useState(false)
const [validationClock, setValidationClock] = useState(Date.now)
const inputRef = useRef(null)
const popoverRef = useRef(null)
const panelRef = useRef(null)
const rootRef = useRef(null)
const constraints = {
allowPast,
min,
max,
reference: new Date(validationClock)
}
const calendarBounds = scheduleCalendarBounds({
...constraints,
timeZone: zone
})
const fields = timeFields(selectedTime, locale)
const minutes = Array.from({ length: 60 }, (_, minute) =>
String(minute).padStart(2, '0')
)
const constraintError = interpretation.iso
? scheduleConstraint(interpretation.iso, constraints)
: ''
const committable = interpretation.state === 'proposal' && !constraintError
const invalid =
interpretation.state === 'invalid' ||
Boolean(constraintError) ||
(touched && interpretation.state === 'incomplete')
let statusText
if (interpretation.state === 'empty')
statusText = 'Type a date and time, or choose them from the calendar.'
else if (interpretation.state === 'invalid')
statusText = 'Enter a date and time, such as tomorrow at 9am.'
else if (interpretation.state === 'incomplete')
statusText = interpretation.message
else if (constraintError === 'past')
statusText = 'Choose a time in the future.'
else if (constraintError === 'min')
statusText = `Choose ${formatSchedule(min, locale, zone)} or later.`
else if (constraintError === 'max')
statusText = `Choose ${formatSchedule(max, locale, zone)} or earlier.`
else if (interpretation.state === 'proposal')
statusText = `${allowPast ? 'Use' : 'Will schedule for'} ${interpretation.label} in ${zone}. Press Enter or leave the picker to use it.`
else
statusText = `${allowPast ? 'Selected' : 'Scheduled for'} ${interpretation.label} in ${zone}.`
const describedBy = [externalDescribedBy, statusId].filter(Boolean).join(' ')
function updateValue(nextValue) {
if (value === undefined) setInternalValue(nextValue)
onValueChange?.(nextValue)
}
function readDraft(nextDraft) {
setValidationClock(Date.now())
setDraft(nextDraft)
if (!nextDraft.trim()) {
updateValue('')
setInterpretation({ state: 'empty' })
return
}
const next = interpretSchedule(nextDraft, {
reference: new Date(),
locale,
timeZone: zone,
allowPast
})
setInterpretation(next)
if (next.date) setSelectedDate(next.date)
if (next.time) setSelectedTime(next.time)
}
function stage(date = selectedDate, time = selectedTime) {
if (disabled || readOnly) return
setValidationClock(Date.now())
// Reselecting an unchanged instant must not discard its seconds.
const iso =
interpretation.iso &&
interpretation.date === date &&
interpretation.time === time
? interpretation.iso
: wallClockToIso({ date, time, timeZone: zone })
setSelectedDate(date)
setSelectedTime(time)
if (!iso) {
setInterpretation({ state: 'invalid' })
return
}
const label = formatSchedule(iso, locale, zone)
setDraft(label)
setInterpretation({ state: 'proposal', iso, date, time, label })
}
function commitProposal({ restoreFocus = true } = {}) {
const reference = new Date()
setValidationClock(reference.getTime())
if (
interpretation.state !== 'proposal' ||
disabled ||
readOnly ||
scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
)
return
updateValue(interpretation.iso)
setDraft(interpretation.label)
setInterpretation({ ...interpretation, state: 'committed' })
popoverRef.current?.close({ restoreFocus })
}
function handleBlur(event) {
if (
event.relatedTarget &&
event.currentTarget.contains(event.relatedTarget)
)
return
setTouched(true)
commitProposal({ restoreFocus: false })
}
function finish() {
const reference = new Date()
setValidationClock(reference.getTime())
if (
disabled ||
readOnly ||
scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
)
return
if (interpretation.state === 'committed') {
popoverRef.current?.close()
return
}
commitProposal()
}
function handleOpenChange(nextOpen) {
setValidationClock(Date.now())
onOpenChange?.(nextOpen)
if (!nextOpen) return
if (interpretation.state === 'empty') {
const wall = initialScheduleWallClock('', {
allowPast,
min,
max,
timeZone: zone,
minuteStep
})
setSelectedDate(wall.date)
setSelectedTime(wall.time)
}
requestAnimationFrame(() => {
if (!panelRef.current?.getClientRects().length) return
panelRef.current.parentElement.scrollTop = 0
})
}
function chooseTimeField(part, nextValue) {
stage(selectedDate, updateTimeField(selectedTime, part, nextValue, locale))
}
useEffect(() => {
const wall = instantToWallClock(committedValue, zone)
if (!wall) {
if (!committedValue) {
setDraft('')
setInterpretation({ state: 'empty' })
}
return
}
const label = formatSchedule(committedValue, locale, zone)
setSelectedDate(wall.date)
setSelectedTime(wall.time)
setDraft(label)
setInterpretation({
state: 'committed',
iso: committedValue,
...wall,
label
})
}, [committedValue, locale, zone])
useEffect(() => {
if (!inputRef.current) return
if (required && !committedValue)
inputRef.current.setCustomValidity('Choose a date and time.')
else if (invalid || interpretation.state === 'incomplete')
inputRef.current.setCustomValidity(statusText)
else inputRef.current.setCustomValidity('')
}, [
committable,
committedValue,
interpretation.state,
invalid,
required,
statusText
])
useImperativeHandle(forwardedRef, () => ({
input: inputRef.current,
focus: (options) => inputRef.current?.focus(options),
open: () => popoverRef.current?.open(),
close: () => popoverRef.current?.close()
}))
return (
<div
ref={rootRef}
data-slot="schedule-picker"
data-state={interpretation.state}
onBlur={handleBlur}
className={twMerge(
'grid w-full gap-2 **:data-[slot=schedule-picker-field]:relative **:data-[slot=schedule-picker-field]:flex **:data-[slot=schedule-picker-field]:items-stretch **:data-[slot=input]:pe-12',
className
)}
>
<div data-slot="schedule-picker-field">
<Input
{...inputProps}
ref={inputRef}
id={inputId}
type="text"
autoComplete="off"
value={draft}
placeholder={placeholder}
required={required}
disabled={disabled}
readOnly={readOnly}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
onChange={(event) => {
onChange?.(event)
if (!event.defaultPrevented) {
setTouched(false)
readDraft(event.target.value)
}
}}
onClick={() => !disabled && !readOnly && popoverRef.current?.open()}
onKeyDown={(event) => {
inputProps.onKeyDown?.(event)
if (event.defaultPrevented) return
if (event.key === 'ArrowDown' && !disabled && !readOnly) {
event.preventDefault()
popoverRef.current?.open()
} else if (
event.key === 'Enter' &&
(interpretation.state === 'proposal' ||
interpretation.state === 'incomplete' ||
interpretation.state === 'invalid' ||
constraintError)
) {
event.preventDefault()
setTouched(true)
commitProposal({ restoreFocus: false })
}
}}
/>
<button
type="button"
popoverTarget={popoverId}
data-slot="schedule-picker-button"
className="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
disabled={disabled || readOnly}
aria-label="Choose a date and time"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className="size-5"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</button>
</div>
{name ? (
<input
type="hidden"
name={name}
value={committedValue}
disabled={disabled}
/>
) : null}
<p
id={statusId}
data-slot="schedule-picker-status"
className="text-sm text-gray-600 aria-invalid:text-red-700 dark:text-gray-400 dark:aria-invalid:text-red-400"
aria-invalid={invalid}
aria-live="polite"
>
{statusText}
</p>
<Popover
ref={popoverRef}
id={popoverId}
anchor={inputId}
open={open}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
placement="bottom-start"
data-slot="schedule-picker-popover"
className="max-h-[min(var(--klean-popover-available-height,100dvh),calc(100dvh-1rem))] w-[min(24rem,calc(100vw-1rem))] overflow-y-auto overscroll-contain rounded-xl p-0"
>
<div ref={panelRef} data-slot="schedule-picker-panel">
<div className="grid">
<Calendar
value={selectedDate}
min={calendarBounds.min}
max={calendarBounds.max}
locale={locale}
dir={dir}
disabled={disabled}
readOnly={readOnly}
className="max-w-none p-4"
onValueChange={(date) => stage(date, selectedTime)}
/>
<section
data-slot="schedule-picker-times"
className="sticky bottom-0 grid min-w-0 gap-2 border-t border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950"
aria-labelledby={timeHeadingId}
>
<div className="flex min-w-0 items-baseline justify-between gap-3">
<h2 id={timeHeadingId} className="text-sm font-medium">
Time
</h2>
<p
id={`${inputId}-time-zone`}
data-slot="schedule-picker-time-zone"
className="min-w-0 text-end text-xs wrap-anywhere text-gray-500 dark:text-gray-400"
>
{zone}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="schedule-picker-time-fields"
role="group"
aria-labelledby={timeHeadingId}
aria-describedby={`${inputId}-time-zone`}
dir="ltr"
className="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-sm font-medium tabular-nums shadow-xs dark:border-gray-700 dark:bg-gray-900"
>
<select
data-slot="schedule-picker-hour"
aria-label="Hour"
className="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
value={fields.hour}
disabled={disabled || readOnly}
onChange={(event) =>
chooseTimeField('hour', event.target.value)
}
>
{fields.hours.map((hour) => (
<option key={hour.value} value={hour.value}>
{hour.label}
</option>
))}
</select>
<span aria-hidden="true" className="text-gray-400">
:
</span>
<select
data-slot="schedule-picker-minute"
aria-label="Minute"
className="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
value={fields.minute}
disabled={disabled || readOnly}
onChange={(event) =>
chooseTimeField('minute', event.target.value)
}
>
{minutes.map((minute) => (
<option key={minute} value={minute}>
{minute}
</option>
))}
</select>
{fields.hour12 ? (
<div className="relative ms-1 border-s border-gray-200 ps-1 dark:border-gray-700">
<select
data-slot="schedule-picker-period"
aria-label="Period"
className="h-11 min-w-16 cursor-pointer appearance-none rounded-md bg-transparent ps-2 pe-6 text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
value={fields.period}
disabled={disabled || readOnly}
onChange={(event) =>
chooseTimeField('period', event.target.value)
}
>
{fields.periods.map((period) => (
<option key={period.value} value={period.value}>
{period.label}
</option>
))}
</select>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute inset-e-2 top-1/2 size-3 -translate-y-1/2 text-gray-500 dark:text-gray-400"
>
<path d="m7 10 5 5 5-5" />
</svg>
</div>
) : null}
</div>
<div data-slot="schedule-picker-footer">
<button
type="button"
data-slot="schedule-picker-confirm"
className="min-h-11 cursor-pointer rounded-lg bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200 dark:focus-visible:outline-white"
disabled={
(!committable && interpretation.state !== 'committed') ||
invalid ||
disabled ||
readOnly
}
onClick={finish}
>
Done
</button>
</div>
</div>
{invalid ? (
<p className="text-sm text-red-700 dark:text-red-400">
{statusText}
</p>
) : null}
</section>
</div>
</div>
</Popover>
</div>
)
})
export default SchedulePicker
import {
CalendarDateTime,
fromAbsolute,
toZoned
} from '@internationalized/date'
import { en as chrono } from 'chrono-node'
import { dateLabel, parseIsoDate, resolveLocale } from '../calendar/date.js'
export function resolveTimeZone(timeZone) {
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const candidate = timeZone || fallback
try {
new Intl.DateTimeFormat('en', { timeZone: candidate }).format()
return candidate
} catch {
return fallback
}
}
export function parseTime(value) {
const match = /^(\d{2}):(\d{2})$/.exec(value ?? '')
if (!match) return undefined
const hour = Number(match[1])
const minute = Number(match[2])
if (hour > 23 || minute > 59) return undefined
return { hour, minute }
}
export function formatTime({ hour, minute }) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
export function wallClockToIso({
date,
time,
timeZone,
timezoneOffset,
second = 0,
millisecond = 0
}) {
const parsedDate = parseIsoDate(date)
const parsedTime = parseTime(time)
if (!parsedDate || !parsedTime) return undefined
if (Number.isFinite(timezoneOffset)) {
return new Date(
Date.UTC(
parsedDate.year,
parsedDate.month - 1,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
) +
-timezoneOffset * 60_000
).toISOString()
}
try {
const zoned = toZoned(
new CalendarDateTime(
parsedDate.year,
parsedDate.month,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
),
resolveTimeZone(timeZone),
'compatible'
)
// A gap must not silently move an entered time forward. During an overlap,
// compatible disambiguation keeps the earlier occurrence.
if (
zoned.year !== parsedDate.year ||
zoned.month !== parsedDate.month ||
zoned.day !== parsedDate.day ||
zoned.hour !== parsedTime.hour ||
zoned.minute !== parsedTime.minute ||
zoned.second !== second ||
zoned.millisecond !== millisecond
) {
return undefined
}
return zoned.toDate().toISOString()
} catch {
return undefined
}
}
export function instantToWallClock(value, timeZone) {
const instant = new Date(value)
if (Number.isNaN(instant.getTime())) return undefined
const zoned = fromAbsolute(instant.getTime(), resolveTimeZone(timeZone))
return {
date: `${String(zoned.year).padStart(4, '0')}-${String(zoned.month).padStart(2, '0')}-${String(zoned.day).padStart(2, '0')}`,
time: formatTime({ hour: zoned.hour, minute: zoned.minute })
}
}
export function formatSchedule(value, locale, timeZone) {
const instant = new Date(value)
if (Number.isNaN(instant.getTime())) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: resolveTimeZone(timeZone),
dateStyle: 'medium',
timeStyle:
instant.getUTCSeconds() || instant.getUTCMilliseconds()
? 'medium'
: 'short'
}).format(instant)
}
export function formatTimeLabel(value, locale) {
const time = parseTime(value)
if (!time) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(Date.UTC(2020, 0, 1, time.hour, time.minute)))
}
function timestamp(value) {
if (value === undefined || value === null || value === '') return NaN
return new Date(value).getTime()
}
function referenceTimestamp(reference) {
const value = timestamp(reference)
return Number.isFinite(value) ? value : Date.now()
}
export function scheduleConstraint(
value,
{ allowPast = false, min, max, reference = new Date() } = {}
) {
const instant = timestamp(value)
if (!Number.isFinite(instant)) return 'invalid'
if (!allowPast && instant <= referenceTimestamp(reference)) return 'past'
if (instant < timestamp(min)) return 'min'
if (instant > timestamp(max)) return 'max'
return ''
}
export function scheduleCalendarBounds({
allowPast = false,
min,
max,
timeZone,
reference = new Date()
} = {}) {
const configuredMin = timestamp(min)
const lower = Math.max(
allowPast ? -Infinity : referenceTimestamp(reference) + 1,
Number.isFinite(configuredMin) ? configuredMin : -Infinity
)
const upper = timestamp(max)
return {
min: Number.isFinite(lower)
? instantToWallClock(lower, timeZone)?.date
: undefined,
max: Number.isFinite(upper)
? instantToWallClock(upper, timeZone)?.date
: undefined
}
}
export function timeFields(value, locale) {
const time = parseTime(value) ?? { hour: 0, minute: 0 }
const resolvedLocale = resolveLocale(locale)
const hour12 = new Intl.DateTimeFormat(resolvedLocale, {
hour: 'numeric'
}).resolvedOptions().hour12
const hoursFormatter = new Intl.DateTimeFormat(resolvedLocale, {
timeZone: 'UTC',
hour: '2-digit',
hourCycle: hour12 ? 'h12' : 'h23'
})
const periodsFormatter = new Intl.DateTimeFormat(resolvedLocale, {
timeZone: 'UTC',
hour: 'numeric',
hourCycle: 'h12'
})
const part = (formatter, hour, type) =>
formatter
.formatToParts(new Date(Date.UTC(2020, 0, 1, hour)))
.find((item) => item.type === type)?.value
return {
hour: String(hour12 ? time.hour % 12 || 12 : time.hour),
minute: String(time.minute).padStart(2, '0'),
period: time.hour < 12 ? 'am' : 'pm',
hour12,
periods: [
{ value: 'am', label: part(periodsFormatter, 6, 'dayPeriod') ?? 'AM' },
{ value: 'pm', label: part(periodsFormatter, 18, 'dayPeriod') ?? 'PM' }
],
hours: Array.from({ length: hour12 ? 12 : 24 }, (_, index) => {
const hour = hour12 ? index + 1 : index
return {
value: String(hour),
label: part(hoursFormatter, hour, 'hour') ?? String(hour)
}
})
}
}
export function updateTimeField(time, part, value, locale) {
const current = parseTime(time)
if (!current) return time
const fields = timeFields(time, locale)
if (part === 'period') {
if (!fields.hour12 || !['am', 'pm'].includes(value)) return time
current.hour = (current.hour % 12) + (value === 'pm' ? 12 : 0)
} else {
if (!/^\d{1,2}$/.test(String(value))) return time
const number = Number(value)
if (part === 'minute') {
if (number > 59) return time
current.minute = number
} else if (part === 'hour') {
if (fields.hour12) {
if (number < 1 || number > 12) return time
current.hour = (number % 12) + (fields.period === 'pm' ? 12 : 0)
} else {
if (number > 23) return time
current.hour = number
}
} else {
return time
}
}
return formatTime(current)
}
function normalizeMinuteStep(step) {
const value = Number(step)
return Number.isFinite(value)
? Math.min(60, Math.max(1, Math.round(value)))
: 15
}
export function timeOptions(step = 15) {
const safeStep = normalizeMinuteStep(step)
const values = []
for (let minute = 0; minute < 24 * 60; minute += safeStep) {
values.push(
formatTime({ hour: Math.floor(minute / 60), minute: minute % 60 })
)
}
return values
}
function roundedFutureTimestamp(reference, step) {
const amount = normalizeMinuteStep(step)
const rounded = new Date(referenceTimestamp(reference))
rounded.setSeconds(0, 0)
const remainder = rounded.getMinutes() % amount
rounded.setMinutes(
rounded.getMinutes() + (remainder ? amount - remainder : amount)
)
return rounded.getTime()
}
export function roundedFutureWallClock(reference, timeZone, step = 15) {
return instantToWallClock(roundedFutureTimestamp(reference, step), timeZone)
}
export function initialScheduleWallClock(
value,
{
allowPast = false,
min,
max,
timeZone,
minuteStep = 15,
reference = new Date()
} = {}
) {
const current = timestamp(value)
if (Number.isFinite(current)) return instantToWallClock(current, timeZone)
const now = referenceTimestamp(reference)
const initial = roundedFutureTimestamp(now, minuteStep)
const configuredMin = timestamp(min)
const configuredMax = timestamp(max)
const lower = Math.max(
allowPast ? -Infinity : now + 1,
Number.isFinite(configuredMin) ? configuredMin : -Infinity
)
const upper = Number.isFinite(configuredMax) ? configuredMax : Infinity
// Bounds still disable every choice when the allowed interval is empty.
// Initializing a view must not imply that a forbidden instant is valid.
const candidate =
lower <= upper ? Math.min(upper, Math.max(lower, initial)) : initial
return instantToWallClock(candidate, timeZone)
}
export function interpretSchedule(
text,
{ reference = new Date(), locale, timeZone, allowPast = false } = {}
) {
const source = text?.trim()
if (!source) return { state: 'empty' }
const zone = resolveTimeZone(timeZone)
const referenceDate =
reference instanceof Date ? reference : new Date(reference)
const referenceInstant = Number.isNaN(referenceDate.getTime())
? new Date()
: referenceDate
const zonedReference = fromAbsolute(referenceInstant.getTime(), zone)
const result = chrono.parse(
source,
{
instant: referenceInstant,
timezone: zonedReference.offset / 60_000
},
{ forwardDate: !allowPast }
)[0]
if (!result) return { state: 'invalid' }
const date = `${String(result.start.get('year')).padStart(4, '0')}-${String(result.start.get('month')).padStart(2, '0')}-${String(result.start.get('day')).padStart(2, '0')}`
const hasTime = result.start.isCertain('hour')
if (!hasTime) {
return {
state: 'incomplete',
date,
message: `${dateLabel(date, locale)} needs a time.`
}
}
const time = formatTime({
hour: result.start.get('hour'),
minute: result.start.get('minute') ?? 0
})
const timezoneOffset = result.start.isCertain('timezoneOffset')
? result.start.get('timezoneOffset')
: undefined
const iso = wallClockToIso({
date,
time,
timeZone: zone,
timezoneOffset,
second: result.start.get('second') ?? 0,
millisecond: result.start.get('millisecond') ?? 0
})
if (!iso) return { state: 'invalid' }
return {
state: 'proposal',
...instantToWallClock(iso, zone),
iso,
label: formatSchedule(iso, locale, zone),
timeZone: zone
}
}
Svelte
<script>
import { untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import Calendar from "../calendar/Calendar.svelte";
import Input from "../input/Input.svelte";
import Popover from "../popover/Popover.svelte";
import {
formatSchedule,
instantToWallClock,
initialScheduleWallClock,
interpretSchedule,
resolveTimeZone,
scheduleCalendarBounds,
scheduleConstraint,
timeFields,
updateTimeField,
wallClockToIso,
} from "./schedule.js";
let {
value = $bindable(),
defaultValue,
onchange,
id,
name,
placeholder = "Tomorrow at 9am",
timeZone,
locale,
dir,
allowPast = false,
min,
max,
minuteStep = 15,
open = $bindable(),
defaultOpen = false,
onopenchange,
required = false,
disabled = false,
readonly = false,
class: className,
"aria-describedby": externalDescribedBy,
...inputProps
} = $props();
const componentId = $props.id();
const generatedId = componentId.replace(/[^a-zA-Z0-9_-]/g, "");
let inputId = $derived(id ?? `klean-schedule-picker-${generatedId}`);
let popoverId = $derived(`${inputId}-panel`);
let statusId = $derived(`${inputId}-status`);
let timeHeadingId = $derived(`${inputId}-time-heading`);
let zone = $derived(resolveTimeZone(timeZone));
const initialValue = untrack(() => {
const candidate = value === undefined ? defaultValue : value;
return Number.isNaN(new Date(candidate).getTime()) ? "" : candidate;
});
if (untrack(() => value) === undefined) value = initialValue;
const initialWall = untrack(() =>
initialScheduleWallClock(initialValue, {
allowPast,
min,
max,
timeZone: zone,
minuteStep,
}),
);
let selectedDate = $state(initialWall.date);
let selectedTime = $state(initialWall.time);
let draft = $state(
initialValue
? formatSchedule(
initialValue,
untrack(() => locale),
untrack(() => zone),
)
: "",
);
let interpretation = $state(
initialValue
? {
state: "committed",
iso: initialValue,
...initialWall,
label: formatSchedule(
initialValue,
untrack(() => locale),
untrack(() => zone),
),
}
: { state: "empty" },
);
let touched = $state(false);
let validationClock = $state(Date.now());
let input;
let popover;
let panel;
let root;
let constraints = $derived({
allowPast,
min,
max,
reference: new Date(validationClock),
});
let calendarBounds = $derived(
scheduleCalendarBounds({ ...constraints, timeZone: zone }),
);
let fields = $derived(timeFields(selectedTime, locale));
const minutes = Array.from({ length: 60 }, (_, minute) =>
String(minute).padStart(2, "0"),
);
let constraintError = $derived(
interpretation.iso
? scheduleConstraint(interpretation.iso, constraints)
: "",
);
let committable = $derived(
interpretation.state === "proposal" && !constraintError,
);
let invalid = $derived(
interpretation.state === "invalid" ||
Boolean(constraintError) ||
(touched && interpretation.state === "incomplete"),
);
let statusText = $derived.by(() => {
if (interpretation.state === "empty")
return "Type a date and time, or choose them from the calendar.";
if (interpretation.state === "invalid")
return "Enter a date and time, such as tomorrow at 9am.";
if (interpretation.state === "incomplete") return interpretation.message;
if (constraintError === "past") return "Choose a time in the future.";
if (constraintError === "min")
return `Choose ${formatSchedule(min, locale, zone)} or later.`;
if (constraintError === "max")
return `Choose ${formatSchedule(max, locale, zone)} or earlier.`;
if (interpretation.state === "proposal")
return `${allowPast ? "Use" : "Will schedule for"} ${interpretation.label} in ${zone}. Press Enter or leave the picker to use it.`;
return `${allowPast ? "Selected" : "Scheduled for"} ${interpretation.label} in ${zone}.`;
});
let describedBy = $derived(
[externalDescribedBy, statusId].filter(Boolean).join(" "),
);
function updateValue(nextValue) {
value = nextValue;
onchange?.(nextValue);
}
function readDraft(nextDraft) {
validationClock = Date.now();
draft = nextDraft;
if (!nextDraft.trim()) {
updateValue("");
interpretation = { state: "empty" };
return;
}
const next = interpretSchedule(nextDraft, {
reference: new Date(),
locale,
timeZone: zone,
allowPast,
});
interpretation = next;
if (next.date) selectedDate = next.date;
if (next.time) selectedTime = next.time;
}
function stage(date = selectedDate, time = selectedTime) {
if (disabled || readonly) return;
validationClock = Date.now();
// Reselecting an unchanged instant must not discard its seconds.
const iso =
interpretation.iso &&
interpretation.date === date &&
interpretation.time === time
? interpretation.iso
: wallClockToIso({ date, time, timeZone: zone });
selectedDate = date;
selectedTime = time;
if (!iso) {
interpretation = { state: "invalid" };
return;
}
const label = formatSchedule(iso, locale, zone);
draft = label;
interpretation = { state: "proposal", iso, date, time, label };
}
function commitProposal({ restoreFocus = true } = {}) {
const reference = new Date();
validationClock = reference.getTime();
if (
interpretation.state !== "proposal" ||
disabled ||
readonly ||
scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
)
return;
updateValue(interpretation.iso);
draft = interpretation.label;
interpretation = { ...interpretation, state: "committed" };
popover?.close({ restoreFocus });
}
function handleFocusOut(event) {
if (event.relatedTarget && root?.contains(event.relatedTarget)) return;
touched = true;
commitProposal({ restoreFocus: false });
}
function finish() {
const reference = new Date();
validationClock = reference.getTime();
if (
disabled ||
readonly ||
scheduleConstraint(interpretation.iso, { allowPast, min, max, reference })
)
return;
if (interpretation.state === "committed") {
popover?.close();
return;
}
commitProposal();
}
function handleOpenChange(nextOpen) {
validationClock = Date.now();
onopenchange?.(nextOpen);
if (!nextOpen) return;
if (interpretation.state === "empty") {
const wall = initialScheduleWallClock("", {
allowPast,
min,
max,
timeZone: zone,
minuteStep,
});
selectedDate = wall.date;
selectedTime = wall.time;
}
requestAnimationFrame(() => {
if (!panel?.getClientRects().length) return;
panel.parentElement.scrollTop = 0;
});
}
function chooseTimeField(part, nextValue) {
stage(selectedDate, updateTimeField(selectedTime, part, nextValue, locale));
}
$effect(() => {
const committedValue = value;
const currentZone = zone;
const currentLocale = locale;
untrack(() => {
const wall = instantToWallClock(committedValue, currentZone);
if (!wall) {
if (!committedValue) {
draft = "";
interpretation = { state: "empty" };
}
return;
}
const label = formatSchedule(committedValue, currentLocale, currentZone);
selectedDate = wall.date;
selectedTime = wall.time;
draft = label;
interpretation = {
state: "committed",
iso: committedValue,
...wall,
label,
};
});
});
$effect(() => {
const element = input?.getElement();
if (!element) return;
if (required && !value)
element.setCustomValidity("Choose a date and time.");
else if (invalid || interpretation.state === "incomplete")
element.setCustomValidity(statusText);
else element.setCustomValidity("");
});
export function focus(options) {
input?.focus(options);
}
export function show() {
popover?.show();
}
export function close() {
popover?.close();
}
</script>
<div
bind:this={root}
data-slot="schedule-picker"
data-state={interpretation.state}
onfocusout={handleFocusOut}
class={twMerge(
"grid w-full gap-2 **:data-[slot=schedule-picker-field]:relative **:data-[slot=schedule-picker-field]:flex **:data-[slot=schedule-picker-field]:items-stretch **:data-[slot=input]:pe-12",
className,
)}
>
<div data-slot="schedule-picker-field">
<Input
{...inputProps}
bind:this={input}
id={inputId}
type="text"
autocomplete="off"
value={draft}
{placeholder}
{required}
{disabled}
{readonly}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
oninput={(event) => {
touched = false;
readDraft(event.target.value);
}}
onclick={() => !disabled && !readonly && popover?.show()}
onkeydown={(event) => {
inputProps.onkeydown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown" && !disabled && !readonly) {
event.preventDefault();
popover?.show();
} else if (
event.key === "Enter" &&
(interpretation.state === "proposal" ||
interpretation.state === "incomplete" ||
interpretation.state === "invalid" ||
constraintError)
) {
event.preventDefault();
touched = true;
commitProposal({ restoreFocus: false });
}
}}
/>
<button
type="button"
popovertarget={popoverId}
data-slot="schedule-picker-button"
class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
disabled={disabled || readonly}
aria-label="Choose a date and time"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-5"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</button>
</div>
{#if name}
<input type="hidden" {name} {value} {disabled} />
{/if}
<p
id={statusId}
data-slot="schedule-picker-status"
class="text-sm text-gray-600 aria-invalid:text-red-700 dark:text-gray-400 dark:aria-invalid:text-red-400"
aria-invalid={invalid}
aria-live="polite"
>
{statusText}
</p>
<Popover
bind:this={popover}
bind:open
id={popoverId}
anchor={inputId}
{defaultOpen}
onOpenChange={handleOpenChange}
placement="bottom-start"
data-slot="schedule-picker-popover"
class="max-h-[min(var(--klean-popover-available-height,100dvh),calc(100dvh-1rem))] w-[min(24rem,calc(100vw-1rem))] overflow-y-auto overscroll-contain rounded-xl p-0"
>
<div bind:this={panel} data-slot="schedule-picker-panel">
<div class="grid">
<Calendar
value={selectedDate}
min={calendarBounds.min}
max={calendarBounds.max}
{locale}
{dir}
{disabled}
{readonly}
class="max-w-none p-4"
onchange={(date) => stage(date, selectedTime)}
/>
<section
data-slot="schedule-picker-times"
class="sticky bottom-0 grid min-w-0 gap-2 border-t border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950"
aria-labelledby={timeHeadingId}
>
<div class="flex min-w-0 items-baseline justify-between gap-3">
<h2 id={timeHeadingId} class="text-sm font-medium">Time</h2>
<p
id={`${inputId}-time-zone`}
data-slot="schedule-picker-time-zone"
class="min-w-0 text-end text-xs wrap-anywhere text-gray-500 dark:text-gray-400"
>
{zone}
</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="schedule-picker-time-fields"
role="group"
aria-labelledby={timeHeadingId}
aria-describedby={`${inputId}-time-zone`}
dir="ltr"
class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-sm font-medium tabular-nums shadow-xs dark:border-gray-700 dark:bg-gray-900"
>
<select
data-slot="schedule-picker-hour"
aria-label="Hour"
class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
value={fields.hour}
disabled={disabled || readonly}
onchange={(event) =>
chooseTimeField("hour", event.target.value)}
>
{#each fields.hours as hour (hour.value)}
<option value={hour.value}>{hour.label}</option>
{/each}
</select>
<span aria-hidden="true" class="text-gray-400">:</span>
<select
data-slot="schedule-picker-minute"
aria-label="Minute"
class="h-11 w-11 cursor-pointer appearance-none rounded-md bg-transparent text-center text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
value={fields.minute}
disabled={disabled || readonly}
onchange={(event) =>
chooseTimeField("minute", event.target.value)}
>
{#each minutes as minute (minute)}
<option value={minute}>{minute}</option>
{/each}
</select>
{#if fields.hour12}
<div
class="relative ms-1 border-s border-gray-200 ps-1 dark:border-gray-700"
>
<select
data-slot="schedule-picker-period"
aria-label="Period"
class="h-11 min-w-16 cursor-pointer appearance-none rounded-md bg-transparent ps-2 pe-6 text-gray-950 hover:bg-gray-200/60 focus-visible:bg-white focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-white dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800 dark:focus-visible:outline-white"
value={fields.period}
disabled={disabled || readonly}
onchange={(event) =>
chooseTimeField("period", event.target.value)}
>
{#each fields.periods as period (period.value)}
<option value={period.value}>{period.label}</option>
{/each}
</select>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="pointer-events-none absolute inset-e-2 top-1/2 size-3 -translate-y-1/2 text-gray-500 dark:text-gray-400"
>
<path d="m7 10 5 5 5-5" />
</svg>
</div>
{/if}
</div>
<div data-slot="schedule-picker-footer">
<button
type="button"
data-slot="schedule-picker-confirm"
class="min-h-11 cursor-pointer rounded-lg bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-200 dark:focus-visible:outline-white"
disabled={(!committable &&
interpretation.state !== "committed") ||
invalid ||
disabled ||
readonly}
onclick={finish}
>
Done
</button>
</div>
</div>
{#if invalid}
<p class="text-sm text-red-700 dark:text-red-400">{statusText}</p>
{/if}
</section>
</div>
</div>
</Popover>
</div>
import {
CalendarDateTime,
fromAbsolute,
toZoned
} from '@internationalized/date'
import { en as chrono } from 'chrono-node'
import { dateLabel, parseIsoDate, resolveLocale } from '../calendar/date.js'
export function resolveTimeZone(timeZone) {
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const candidate = timeZone || fallback
try {
new Intl.DateTimeFormat('en', { timeZone: candidate }).format()
return candidate
} catch {
return fallback
}
}
export function parseTime(value) {
const match = /^(\d{2}):(\d{2})$/.exec(value ?? '')
if (!match) return undefined
const hour = Number(match[1])
const minute = Number(match[2])
if (hour > 23 || minute > 59) return undefined
return { hour, minute }
}
export function formatTime({ hour, minute }) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
export function wallClockToIso({
date,
time,
timeZone,
timezoneOffset,
second = 0,
millisecond = 0
}) {
const parsedDate = parseIsoDate(date)
const parsedTime = parseTime(time)
if (!parsedDate || !parsedTime) return undefined
if (Number.isFinite(timezoneOffset)) {
return new Date(
Date.UTC(
parsedDate.year,
parsedDate.month - 1,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
) +
-timezoneOffset * 60_000
).toISOString()
}
try {
const zoned = toZoned(
new CalendarDateTime(
parsedDate.year,
parsedDate.month,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
),
resolveTimeZone(timeZone),
'compatible'
)
// A gap must not silently move an entered time forward. During an overlap,
// compatible disambiguation keeps the earlier occurrence.
if (
zoned.year !== parsedDate.year ||
zoned.month !== parsedDate.month ||
zoned.day !== parsedDate.day ||
zoned.hour !== parsedTime.hour ||
zoned.minute !== parsedTime.minute ||
zoned.second !== second ||
zoned.millisecond !== millisecond
) {
return undefined
}
return zoned.toDate().toISOString()
} catch {
return undefined
}
}
export function instantToWallClock(value, timeZone) {
const instant = new Date(value)
if (Number.isNaN(instant.getTime())) return undefined
const zoned = fromAbsolute(instant.getTime(), resolveTimeZone(timeZone))
return {
date: `${String(zoned.year).padStart(4, '0')}-${String(zoned.month).padStart(2, '0')}-${String(zoned.day).padStart(2, '0')}`,
time: formatTime({ hour: zoned.hour, minute: zoned.minute })
}
}
export function formatSchedule(value, locale, timeZone) {
const instant = new Date(value)
if (Number.isNaN(instant.getTime())) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: resolveTimeZone(timeZone),
dateStyle: 'medium',
timeStyle:
instant.getUTCSeconds() || instant.getUTCMilliseconds()
? 'medium'
: 'short'
}).format(instant)
}
export function formatTimeLabel(value, locale) {
const time = parseTime(value)
if (!time) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(Date.UTC(2020, 0, 1, time.hour, time.minute)))
}
function timestamp(value) {
if (value === undefined || value === null || value === '') return NaN
return new Date(value).getTime()
}
function referenceTimestamp(reference) {
const value = timestamp(reference)
return Number.isFinite(value) ? value : Date.now()
}
export function scheduleConstraint(
value,
{ allowPast = false, min, max, reference = new Date() } = {}
) {
const instant = timestamp(value)
if (!Number.isFinite(instant)) return 'invalid'
if (!allowPast && instant <= referenceTimestamp(reference)) return 'past'
if (instant < timestamp(min)) return 'min'
if (instant > timestamp(max)) return 'max'
return ''
}
export function scheduleCalendarBounds({
allowPast = false,
min,
max,
timeZone,
reference = new Date()
} = {}) {
const configuredMin = timestamp(min)
const lower = Math.max(
allowPast ? -Infinity : referenceTimestamp(reference) + 1,
Number.isFinite(configuredMin) ? configuredMin : -Infinity
)
const upper = timestamp(max)
return {
min: Number.isFinite(lower)
? instantToWallClock(lower, timeZone)?.date
: undefined,
max: Number.isFinite(upper)
? instantToWallClock(upper, timeZone)?.date
: undefined
}
}
export function timeFields(value, locale) {
const time = parseTime(value) ?? { hour: 0, minute: 0 }
const resolvedLocale = resolveLocale(locale)
const hour12 = new Intl.DateTimeFormat(resolvedLocale, {
hour: 'numeric'
}).resolvedOptions().hour12
const hoursFormatter = new Intl.DateTimeFormat(resolvedLocale, {
timeZone: 'UTC',
hour: '2-digit',
hourCycle: hour12 ? 'h12' : 'h23'
})
const periodsFormatter = new Intl.DateTimeFormat(resolvedLocale, {
timeZone: 'UTC',
hour: 'numeric',
hourCycle: 'h12'
})
const part = (formatter, hour, type) =>
formatter
.formatToParts(new Date(Date.UTC(2020, 0, 1, hour)))
.find((item) => item.type === type)?.value
return {
hour: String(hour12 ? time.hour % 12 || 12 : time.hour),
minute: String(time.minute).padStart(2, '0'),
period: time.hour < 12 ? 'am' : 'pm',
hour12,
periods: [
{ value: 'am', label: part(periodsFormatter, 6, 'dayPeriod') ?? 'AM' },
{ value: 'pm', label: part(periodsFormatter, 18, 'dayPeriod') ?? 'PM' }
],
hours: Array.from({ length: hour12 ? 12 : 24 }, (_, index) => {
const hour = hour12 ? index + 1 : index
return {
value: String(hour),
label: part(hoursFormatter, hour, 'hour') ?? String(hour)
}
})
}
}
export function updateTimeField(time, part, value, locale) {
const current = parseTime(time)
if (!current) return time
const fields = timeFields(time, locale)
if (part === 'period') {
if (!fields.hour12 || !['am', 'pm'].includes(value)) return time
current.hour = (current.hour % 12) + (value === 'pm' ? 12 : 0)
} else {
if (!/^\d{1,2}$/.test(String(value))) return time
const number = Number(value)
if (part === 'minute') {
if (number > 59) return time
current.minute = number
} else if (part === 'hour') {
if (fields.hour12) {
if (number < 1 || number > 12) return time
current.hour = (number % 12) + (fields.period === 'pm' ? 12 : 0)
} else {
if (number > 23) return time
current.hour = number
}
} else {
return time
}
}
return formatTime(current)
}
function normalizeMinuteStep(step) {
const value = Number(step)
return Number.isFinite(value)
? Math.min(60, Math.max(1, Math.round(value)))
: 15
}
export function timeOptions(step = 15) {
const safeStep = normalizeMinuteStep(step)
const values = []
for (let minute = 0; minute < 24 * 60; minute += safeStep) {
values.push(
formatTime({ hour: Math.floor(minute / 60), minute: minute % 60 })
)
}
return values
}
function roundedFutureTimestamp(reference, step) {
const amount = normalizeMinuteStep(step)
const rounded = new Date(referenceTimestamp(reference))
rounded.setSeconds(0, 0)
const remainder = rounded.getMinutes() % amount
rounded.setMinutes(
rounded.getMinutes() + (remainder ? amount - remainder : amount)
)
return rounded.getTime()
}
export function roundedFutureWallClock(reference, timeZone, step = 15) {
return instantToWallClock(roundedFutureTimestamp(reference, step), timeZone)
}
export function initialScheduleWallClock(
value,
{
allowPast = false,
min,
max,
timeZone,
minuteStep = 15,
reference = new Date()
} = {}
) {
const current = timestamp(value)
if (Number.isFinite(current)) return instantToWallClock(current, timeZone)
const now = referenceTimestamp(reference)
const initial = roundedFutureTimestamp(now, minuteStep)
const configuredMin = timestamp(min)
const configuredMax = timestamp(max)
const lower = Math.max(
allowPast ? -Infinity : now + 1,
Number.isFinite(configuredMin) ? configuredMin : -Infinity
)
const upper = Number.isFinite(configuredMax) ? configuredMax : Infinity
// Bounds still disable every choice when the allowed interval is empty.
// Initializing a view must not imply that a forbidden instant is valid.
const candidate =
lower <= upper ? Math.min(upper, Math.max(lower, initial)) : initial
return instantToWallClock(candidate, timeZone)
}
export function interpretSchedule(
text,
{ reference = new Date(), locale, timeZone, allowPast = false } = {}
) {
const source = text?.trim()
if (!source) return { state: 'empty' }
const zone = resolveTimeZone(timeZone)
const referenceDate =
reference instanceof Date ? reference : new Date(reference)
const referenceInstant = Number.isNaN(referenceDate.getTime())
? new Date()
: referenceDate
const zonedReference = fromAbsolute(referenceInstant.getTime(), zone)
const result = chrono.parse(
source,
{
instant: referenceInstant,
timezone: zonedReference.offset / 60_000
},
{ forwardDate: !allowPast }
)[0]
if (!result) return { state: 'invalid' }
const date = `${String(result.start.get('year')).padStart(4, '0')}-${String(result.start.get('month')).padStart(2, '0')}-${String(result.start.get('day')).padStart(2, '0')}`
const hasTime = result.start.isCertain('hour')
if (!hasTime) {
return {
state: 'incomplete',
date,
message: `${dateLabel(date, locale)} needs a time.`
}
}
const time = formatTime({
hour: result.start.get('hour'),
minute: result.start.get('minute') ?? 0
})
const timezoneOffset = result.start.isCertain('timezoneOffset')
? result.start.get('timezoneOffset')
: undefined
const iso = wallClockToIso({
date,
time,
timeZone: zone,
timezoneOffset,
second: result.start.get('second') ?? 0,
millisecond: result.start.get('millisecond') ?? 0
})
if (!iso) return { state: 'invalid' }
return {
state: 'proposal',
...instantToWallClock(iso, zone),
iso,
label: formatSchedule(iso, locale, zone),
timeZone: zone
}
}