Schedule Picker
Schedule Picker turns a future wall-clock intention into an exact ISO instant. Natural language, Calendar, and time choices share one field, while a visible interpretation keeps the exact instant honest before Enter or composite blur commits it.
Installation
The registry resolves Input, Popover, and Calendar, then installs the framework-native Schedule Picker and its focused scheduling helper:
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
There is no provider, locale configuration, timezone database setup, or natural-language mode to enable.
When to use
Use Schedule Picker for publishing, sending, deploying, appointments, and jobs that must happen at a future moment.
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.
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
/>
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 Use this time to commit. Moving focus between the text field, Calendar, time list, and footer action does not commit prematurely. Until a valid commit point, the named hidden form value retains the last valid ISO instant. An incomplete phrase, parser mistake, or abandoned invalid edit cannot silently reschedule server work.
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.
The component handles timezone offset changes for the selected date, including daylight-saving transitions. Invalid timezone input falls back to the browser timezone rather than breaking the field.
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 |
| Earliest instant | min | min | min |
| Time-list spacing | minute-step | minuteStep | minuteStep |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Native states | required, disabled, readonly | required, disabled, readOnly | required, disabled, readonly |
Scheduling is future-only by default. min may move the earliest acceptable instant later. minuteStep changes the conventional time list; natural input may remain more precise.
Durable behavior
- Draft text and the last committed instant are separate truths until Enter or a true composite blur.
- Enter commits without moving focus from the field.
- Internal focus movement does not commit; leaving the complete picker does.
- Calendar and time-list choices are keyboard navigable.
- Opening the time list brings the selected time into view.
- Past proposals are invalid and cannot be committed.
- 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 { todayIso } from '../calendar/date.js'
import Input from '../input/Input.vue'
import Popover from '../popover/Popover.vue'
import {
formatSchedule,
formatTimeLabel,
instantToWallClock,
interpretSchedule,
resolveTimeZone,
roundedFutureWallClock,
timeOptions,
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 },
/** Minutes between default time choices. Natural input may be more precise. */
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 =
instantToWallClock(value.value, zone.value) ||
roundedFutureWallClock(new Date(), zone.value, 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 minimumTimestamp = computed(() => {
const configured = new Date(props.min).getTime()
return Math.max(Date.now(), Number.isNaN(configured) ? -Infinity : configured)
})
const calendarMin = computed(() => {
const instant = new Date(minimumTimestamp.value + 1000).toISOString()
return instantToWallClock(instant, zone.value)?.date ?? todayIso(zone.value)
})
const choices = computed(() => timeOptions(props.minuteStep))
const proposalIsPast = computed(
() =>
interpretation.value.state === 'proposal' &&
new Date(interpretation.value.iso).getTime() <= minimumTimestamp.value
)
const committable = computed(
() => interpretation.value.state === 'proposal' && !proposalIsPast.value
)
const invalid = computed(
() =>
interpretation.value.state === 'invalid' ||
proposalIsPast.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 (proposalIsPast.value) return 'Choose a time in the future.'
if (interpretation.value.state === 'proposal') {
return `Will schedule for ${interpretation.value.label} in ${zone.value}. Press Enter or leave the picker to use it.`
}
return `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) {
draft.value = nextDraft
if (!nextDraft.trim()) {
clear()
return
}
const next = interpretSchedule(nextDraft, {
reference: new Date(),
locale: props.locale,
timeZone: zone.value
})
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) {
const iso = wallClockToIso({ date, time, timeZone: zone.value })
if (!iso) {
interpretation.value = { state: 'invalid' }
return
}
selectedDate.value = date
selectedTime.value = time
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 } = {}) {
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 handleInputKeydown(event) {
if (event.key === 'ArrowDown' && !props.disabled && !props.readonly) {
event.preventDefault()
popover.value?.open()
} else if (event.key === 'Enter' && committable.value) {
event.preventDefault()
commitProposal({ restoreFocus: false })
}
}
async function handleOpenUpdate(nextOpen) {
emit('update:open', nextOpen)
if (!nextOpen) return
await nextTick()
panel.value
?.querySelector(`[data-time="${selectedTime.value}"]`)
?.scrollIntoView?.({ block: 'center' })
}
function chooseDate(nextDate) {
stage(nextDate, selectedTime.value)
}
function chooseTime(nextTime) {
stage(selectedDate.value, nextTime)
}
function timeIsUnavailable(time) {
const iso = wallClockToIso({
date: selectedDate.value,
time,
timeZone: zone.value
})
return !iso || new Date(iso).getTime() <= minimumTimestamp.value
}
async function focusTime(nextTime) {
selectedTime.value = nextTime
await nextTick()
panel.value
?.querySelector(`[data-time="${nextTime}"]`)
?.focus({ preventScroll: true })
}
function handleTimeKeydown(event, index) {
let nextIndex
if (event.key === 'ArrowDown')
nextIndex = Math.min(index + 1, choices.value.length - 1)
else if (event.key === 'ArrowUp') nextIndex = Math.max(index - 1, 0)
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = choices.value.length - 1
else return
event.preventDefault()
const direction = nextIndex >= index ? 1 : -1
while (
nextIndex >= 0 &&
nextIndex < choices.value.length &&
timeIsUnavailable(choices.value[nextIndex])
) {
nextIndex += direction
}
if (choices.value[nextIndex]) focusTime(choices.value[nextIndex])
}
watch(value, (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 schedule.')
} else if (invalid.value) {
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"
data-slot="schedule-picker-input"
@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" />
<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"
:open="open"
:default-open="defaultOpen"
placement="bottom-start"
data-slot="schedule-picker-popover"
class="w-[min(42rem,calc(100vw-1rem))] p-0"
@update:open="handleOpenUpdate"
>
<div ref="panel" data-slot="schedule-picker-panel">
<div class="grid sm:grid-cols-[minmax(0,1fr)_10rem]">
<Calendar
:model-value="selectedDate"
:min="calendarMin"
: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="border-t border-gray-200 p-3 sm:border-s sm:border-t-0 dark:border-gray-700"
:aria-labelledby="timeHeadingId"
>
<h2 :id="timeHeadingId" class="px-2 pb-2 text-sm font-semibold">
Time
</h2>
<div
role="listbox"
aria-label="Choose a time"
class="max-h-64 overflow-y-auto overscroll-contain"
>
<button
v-for="(time, index) in choices"
:key="time"
type="button"
role="option"
data-slot="schedule-picker-time"
:data-time="time"
:aria-selected="time === selectedTime"
:disabled="timeIsUnavailable(time)"
:tabindex="time === selectedTime ? 0 : -1"
class="block min-h-11 w-full rounded-md px-3 text-start text-sm tabular-nums hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:text-gray-300 aria-selected:bg-gray-950 aria-selected:font-semibold aria-selected:text-white dark:hover:bg-gray-800 dark:focus-visible:outline-white dark:disabled:text-gray-700 dark:aria-selected:bg-white dark:aria-selected:text-gray-950"
@click="chooseTime(time)"
@keydown="handleTimeKeydown($event, index)"
>
{{ formatTimeLabel(time, locale) }}
</button>
</div>
</section>
</div>
<footer
data-slot="schedule-picker-footer"
class="flex flex-col gap-3 border-t border-gray-200 p-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700"
>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ statusText }}
</p>
<button
type="button"
data-slot="schedule-picker-confirm"
class="min-h-11 shrink-0 rounded-md bg-gray-950 px-4 py-2 text-sm font-semibold 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 || disabled || readonly"
@click="commitProposal()"
>
Use this time
</button>
</footer>
</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 {
return toZoned(
new CalendarDateTime(
parsedDate.year,
parsedDate.month,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
),
resolveTimeZone(timeZone),
'compatible'
)
.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)))
}
export function timeOptions(step = 15) {
const safeStep = Math.min(60, Math.max(1, Math.round(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
}
export function roundedFutureWallClock(reference, timeZone, step = 15) {
const amount = Math.min(60, Math.max(1, Math.round(step)))
const rounded = new Date(reference)
rounded.setSeconds(0, 0)
const remainder = rounded.getMinutes() % amount
rounded.setMinutes(
rounded.getMinutes() + (remainder ? amount - remainder : amount)
)
return instantToWallClock(rounded.toISOString(), timeZone)
}
export function interpretSchedule(
text,
{ reference = new Date(), locale, timeZone } = {}
) {
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: true }
)[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',
date,
time,
iso,
label: formatSchedule(iso, locale, zone),
timeZone: zone
}
}
React
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useMemo,
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,
formatTimeLabel,
instantToWallClock,
interpretSchedule,
resolveTimeZone,
roundedFutureWallClock,
timeOptions,
wallClockToIso
} from './schedule.js'
const SchedulePicker = forwardRef(function SchedulePicker(
{
value,
defaultValue,
onValueChange,
onChange,
id,
name,
placeholder = 'Tomorrow at 9am',
timeZone,
locale,
dir,
min,
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 =
instantToWallClock(committedValue, zone) ||
roundedFutureWallClock(new Date(), 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 inputRef = useRef(null)
const popoverRef = useRef(null)
const panelRef = useRef(null)
const rootRef = useRef(null)
const configuredMinimum = new Date(min).getTime()
const minimumTimestamp = Math.max(
Date.now(),
Number.isNaN(configuredMinimum) ? -Infinity : configuredMinimum
)
const calendarMin = instantToWallClock(
new Date(minimumTimestamp + 1000).toISOString(),
zone
).date
const choices = useMemo(() => timeOptions(minuteStep), [minuteStep])
const proposalIsPast =
interpretation.state === 'proposal' &&
new Date(interpretation.iso).getTime() <= minimumTimestamp
const committable = interpretation.state === 'proposal' && !proposalIsPast
const invalid =
interpretation.state === 'invalid' ||
proposalIsPast ||
(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 (proposalIsPast) statusText = 'Choose a time in the future.'
else if (interpretation.state === 'proposal')
statusText = `Will schedule for ${interpretation.label} in ${zone}. Press Enter or leave the picker to use it.`
else statusText = `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) {
setDraft(nextDraft)
if (!nextDraft.trim()) {
updateValue('')
setInterpretation({ state: 'empty' })
return
}
const next = interpretSchedule(nextDraft, {
reference: new Date(),
locale,
timeZone: zone
})
setInterpretation(next)
if (next.date) setSelectedDate(next.date)
if (next.time) setSelectedTime(next.time)
}
function stage(date = selectedDate, time = selectedTime) {
const iso = wallClockToIso({ date, time, timeZone: zone })
if (!iso) {
setInterpretation({ state: 'invalid' })
return
}
const label = formatSchedule(iso, locale, zone)
setSelectedDate(date)
setSelectedTime(time)
setDraft(label)
setInterpretation({ state: 'proposal', iso, date, time, label })
}
function commitProposal({ restoreFocus = true } = {}) {
if (!committable || disabled || readOnly) 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 handleOpenChange(nextOpen) {
onOpenChange?.(nextOpen)
if (!nextOpen) return
queueMicrotask(() =>
panelRef.current
?.querySelector(`[data-time="${selectedTime}"]`)
?.scrollIntoView?.({ block: 'center' })
)
}
function timeIsUnavailable(time) {
const iso = wallClockToIso({ date: selectedDate, time, timeZone: zone })
return !iso || new Date(iso).getTime() <= minimumTimestamp
}
function handleTimeKeyDown(event, index) {
let nextIndex
if (event.key === 'ArrowDown')
nextIndex = Math.min(index + 1, choices.length - 1)
else if (event.key === 'ArrowUp') nextIndex = Math.max(index - 1, 0)
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = choices.length - 1
else return
event.preventDefault()
const movement = nextIndex >= index ? 1 : -1
while (
nextIndex >= 0 &&
nextIndex < choices.length &&
timeIsUnavailable(choices[nextIndex])
) {
nextIndex += movement
}
const nextTime = choices[nextIndex]
if (!nextTime) return
setSelectedTime(nextTime)
queueMicrotask(() =>
panelRef.current
?.querySelector(`[data-time="${nextTime}"]`)
?.focus({ preventScroll: true })
)
}
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 schedule.')
else if (invalid) inputRef.current.setCustomValidity(statusText)
else inputRef.current.setCustomValidity('')
}, [committable, committedValue, 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}
data-slot="schedule-picker-input"
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' && committable) {
event.preventDefault()
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} /> : 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}
open={open}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
placement="bottom-start"
data-slot="schedule-picker-popover"
className="w-[min(42rem,calc(100vw-1rem))] p-0"
>
<div ref={panelRef} data-slot="schedule-picker-panel">
<div className="grid sm:grid-cols-[minmax(0,1fr)_10rem]">
<Calendar
value={selectedDate}
min={calendarMin}
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="border-t border-gray-200 p-3 sm:border-s sm:border-t-0 dark:border-gray-700"
aria-labelledby={timeHeadingId}
>
<h2
id={timeHeadingId}
className="px-2 pb-2 text-sm font-semibold"
>
Time
</h2>
<div
role="listbox"
aria-label="Choose a time"
className="max-h-64 overflow-y-auto overscroll-contain"
>
{choices.map((time, index) => (
<button
key={time}
type="button"
role="option"
data-slot="schedule-picker-time"
data-time={time}
aria-selected={time === selectedTime}
disabled={timeIsUnavailable(time)}
tabIndex={time === selectedTime ? 0 : -1}
className="block min-h-11 w-full rounded-md px-3 text-start text-sm tabular-nums hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:text-gray-300 aria-selected:bg-gray-950 aria-selected:font-semibold aria-selected:text-white dark:hover:bg-gray-800 dark:focus-visible:outline-white dark:disabled:text-gray-700 dark:aria-selected:bg-white dark:aria-selected:text-gray-950"
onClick={() => stage(selectedDate, time)}
onKeyDown={(event) => handleTimeKeyDown(event, index)}
>
{formatTimeLabel(time, locale)}
</button>
))}
</div>
</section>
</div>
<footer
data-slot="schedule-picker-footer"
className="flex flex-col gap-3 border-t border-gray-200 p-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700"
>
<p className="text-sm text-gray-600 dark:text-gray-400">
{statusText}
</p>
<button
type="button"
data-slot="schedule-picker-confirm"
className="min-h-11 shrink-0 rounded-md bg-gray-950 px-4 py-2 text-sm font-semibold 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 || disabled || readOnly}
onClick={() => commitProposal()}
>
Use this time
</button>
</footer>
</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 {
return toZoned(
new CalendarDateTime(
parsedDate.year,
parsedDate.month,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
),
resolveTimeZone(timeZone),
'compatible'
)
.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)))
}
export function timeOptions(step = 15) {
const safeStep = Math.min(60, Math.max(1, Math.round(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
}
export function roundedFutureWallClock(reference, timeZone, step = 15) {
const amount = Math.min(60, Math.max(1, Math.round(step)))
const rounded = new Date(reference)
rounded.setSeconds(0, 0)
const remainder = rounded.getMinutes() % amount
rounded.setMinutes(
rounded.getMinutes() + (remainder ? amount - remainder : amount)
)
return instantToWallClock(rounded.toISOString(), timeZone)
}
export function interpretSchedule(
text,
{ reference = new Date(), locale, timeZone } = {}
) {
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: true }
)[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',
date,
time,
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,
formatTimeLabel,
instantToWallClock,
interpretSchedule,
resolveTimeZone,
roundedFutureWallClock,
timeOptions,
wallClockToIso,
} from "./schedule.js";
let {
value = $bindable(),
defaultValue,
onchange,
id,
name,
placeholder = "Tomorrow at 9am",
timeZone,
locale,
dir,
min,
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 =
instantToWallClock(
initialValue,
untrack(() => zone),
) ||
roundedFutureWallClock(
new Date(),
untrack(() => zone),
untrack(() => 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 input;
let popover;
let panel;
let root;
let minimumTimestamp = $derived.by(() => {
const configured = new Date(min).getTime();
return Math.max(
Date.now(),
Number.isNaN(configured) ? -Infinity : configured,
);
});
let calendarMin = $derived(
instantToWallClock(new Date(minimumTimestamp + 1000).toISOString(), zone)
.date,
);
let choices = $derived(timeOptions(minuteStep));
let proposalIsPast = $derived(
interpretation.state === "proposal" &&
new Date(interpretation.iso).getTime() <= minimumTimestamp,
);
let committable = $derived(
interpretation.state === "proposal" && !proposalIsPast,
);
let invalid = $derived(
interpretation.state === "invalid" ||
proposalIsPast ||
(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 (proposalIsPast) return "Choose a time in the future.";
if (interpretation.state === "proposal")
return `Will schedule for ${interpretation.label} in ${zone}. Press Enter or leave the picker to use it.`;
return `Scheduled for ${interpretation.label} in ${zone}.`;
});
let describedBy = $derived(
[externalDescribedBy, statusId].filter(Boolean).join(" "),
);
function updateValue(nextValue) {
value = nextValue;
onchange?.(nextValue);
}
function readDraft(nextDraft) {
draft = nextDraft;
if (!nextDraft.trim()) {
updateValue("");
interpretation = { state: "empty" };
return;
}
const next = interpretSchedule(nextDraft, {
reference: new Date(),
locale,
timeZone: zone,
});
interpretation = next;
if (next.date) selectedDate = next.date;
if (next.time) selectedTime = next.time;
}
function stage(date = selectedDate, time = selectedTime) {
const iso = wallClockToIso({ date, time, timeZone: zone });
if (!iso) {
interpretation = { state: "invalid" };
return;
}
const label = formatSchedule(iso, locale, zone);
selectedDate = date;
selectedTime = time;
draft = label;
interpretation = { state: "proposal", iso, date, time, label };
}
function commitProposal({ restoreFocus = true } = {}) {
if (!committable || disabled || readonly) 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 handleOpenChange(nextOpen) {
onopenchange?.(nextOpen);
if (!nextOpen) return;
queueMicrotask(() =>
panel
?.querySelector(`[data-time="${selectedTime}"]`)
?.scrollIntoView?.({ block: "center" }),
);
}
function timeIsUnavailable(time) {
const iso = wallClockToIso({ date: selectedDate, time, timeZone: zone });
return !iso || new Date(iso).getTime() <= minimumTimestamp;
}
function handleTimeKeydown(event, index) {
let nextIndex;
if (event.key === "ArrowDown")
nextIndex = Math.min(index + 1, choices.length - 1);
else if (event.key === "ArrowUp") nextIndex = Math.max(index - 1, 0);
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = choices.length - 1;
else return;
event.preventDefault();
const movement = nextIndex >= index ? 1 : -1;
while (
nextIndex >= 0 &&
nextIndex < choices.length &&
timeIsUnavailable(choices[nextIndex])
) {
nextIndex += movement;
}
const nextTime = choices[nextIndex];
if (!nextTime) return;
selectedTime = nextTime;
queueMicrotask(() =>
panel
?.querySelector(`[data-time="${nextTime}"]`)
?.focus({ preventScroll: true }),
);
}
$effect(() => {
const wall = instantToWallClock(value, zone);
if (!wall) {
if (!value && interpretation.state === "committed") {
draft = "";
interpretation = { state: "empty" };
}
return;
}
if (interpretation.state === "proposal" && interpretation.iso === value)
return;
const label = formatSchedule(value, locale, zone);
selectedDate = wall.date;
selectedTime = wall.time;
draft = label;
interpretation = { state: "committed", iso: value, ...wall, label };
});
$effect(() => {
const element = input?.getElement();
if (!element) return;
if (required && !value) element.setCustomValidity("Choose a schedule.");
else if (invalid) 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}
data-slot="schedule-picker-input"
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" && committable) {
event.preventDefault();
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} />
{/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}
{defaultOpen}
onOpenChange={handleOpenChange}
placement="bottom-start"
data-slot="schedule-picker-popover"
class="w-[min(42rem,calc(100vw-1rem))] p-0"
>
<div bind:this={panel} data-slot="schedule-picker-panel">
<div class="grid sm:grid-cols-[minmax(0,1fr)_10rem]">
<Calendar
value={selectedDate}
min={calendarMin}
{locale}
{dir}
{disabled}
{readonly}
class="max-w-none p-4"
onchange={(date) => stage(date, selectedTime)}
/>
<section
data-slot="schedule-picker-times"
class="border-t border-gray-200 p-3 sm:border-s sm:border-t-0 dark:border-gray-700"
aria-labelledby={timeHeadingId}
>
<h2 id={timeHeadingId} class="px-2 pb-2 text-sm font-semibold">
Time
</h2>
<div
role="listbox"
aria-label="Choose a time"
class="max-h-64 overflow-y-auto overscroll-contain"
>
{#each choices as time, index (time)}
<button
type="button"
role="option"
data-slot="schedule-picker-time"
data-time={time}
aria-selected={time === selectedTime}
disabled={timeIsUnavailable(time)}
tabindex={time === selectedTime ? 0 : -1}
class="block min-h-11 w-full rounded-md px-3 text-start text-sm tabular-nums hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:text-gray-300 aria-selected:bg-gray-950 aria-selected:font-semibold aria-selected:text-white dark:hover:bg-gray-800 dark:focus-visible:outline-white dark:disabled:text-gray-700 dark:aria-selected:bg-white dark:aria-selected:text-gray-950"
onclick={() => stage(selectedDate, time)}
onkeydown={(event) => handleTimeKeydown(event, index)}
>
{formatTimeLabel(time, locale)}
</button>
{/each}
</div>
</section>
</div>
<footer
data-slot="schedule-picker-footer"
class="flex flex-col gap-3 border-t border-gray-200 p-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700"
>
<p class="text-sm text-gray-600 dark:text-gray-400">{statusText}</p>
<button
type="button"
data-slot="schedule-picker-confirm"
class="min-h-11 shrink-0 rounded-md bg-gray-950 px-4 py-2 text-sm font-semibold 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 || disabled || readonly}
onclick={() => commitProposal()}
>
Use this time
</button>
</footer>
</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 {
return toZoned(
new CalendarDateTime(
parsedDate.year,
parsedDate.month,
parsedDate.day,
parsedTime.hour,
parsedTime.minute,
second,
millisecond
),
resolveTimeZone(timeZone),
'compatible'
)
.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)))
}
export function timeOptions(step = 15) {
const safeStep = Math.min(60, Math.max(1, Math.round(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
}
export function roundedFutureWallClock(reference, timeZone, step = 15) {
const amount = Math.min(60, Math.max(1, Math.round(step)))
const rounded = new Date(reference)
rounded.setSeconds(0, 0)
const remainder = rounded.getMinutes() % amount
rounded.setMinutes(
rounded.getMinutes() + (remainder ? amount - remainder : amount)
)
return instantToWallClock(rounded.toISOString(), timeZone)
}
export function interpretSchedule(
text,
{ reference = new Date(), locale, timeZone } = {}
) {
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: true }
)[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',
date,
time,
iso,
label: formatSchedule(iso, locale, zone),
timeZone: zone
}
}