Skip to content

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.

SchedulePicker.vue
Try “tomorrow at 9am”, “Friday at 14:30”, “in 5 minutes”, or “in one hour”.

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.

Terminal
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

PublishSchedule.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

PublishSchedule.jsx
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

PublishSchedule.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 9am
  • Friday at 14:30
  • in 5 minutes
  • in 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

PurposeVueReactSvelte
Current ISO instantv-modelvalue, onValueChangebind:value
Initial instantdefault-valuedefaultValuedefaultValue
Native form namenamenamename
Interpretation timezonetime-zonetimeZonetimeZone
Localelocale, dirlocale, dirlocale, dir
Earliest instantminminmin
Time-list spacingminute-stepminuteStepminuteStep
Open statev-model:openopen, onOpenChangebind:open
Native statesrequired, disabled, readonlyrequired, disabled, readOnlyrequired, 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.

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-DD value without time or timezone.
  • Calendar — an always-visible date-only YYYY-MM-DD surface.
  • Date Range Picker — ordered date-only YYYY-MM-DD periods.
  • Popover — the non-modal floating behavior.
  • Toast — announce the server result after a schedule is saved.

Complete framework source

Vue

SchedulePicker.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>

schedule.js
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

SchedulePicker.jsx
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

schedule.js
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

SchedulePicker.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>

schedule.js
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
  }
}

All open source projects are released under the MIT License.